feat/add comprehensive e2e testing (#207)

* add an exmaple with a static svelte component in a live view parent with list

* adjusted the styling of example

* chore: added more e2e tests

* chore: preserve client state

* chore: added tests for simple counter

* chore: added live lights e2e tests

* chore: added sigil e2e tests

* chore: added plus/minus tests

* chore: added live plus/minus tests

* chore: added hybrid plus/minus tests

* chore: added static color demo tests

* fix: handle correctly v sigil props

* chore: added tests to log list example

* chore: added tests to breaking news example

* chore: added chat tests

* chore: added tests for live json

* chore: added tests for simple slots

* chore: added tests for dynamic slots. added missing test ids

* chore: add tests to client loading

* chore: addes tests to otp ecto example

* chore: prepare for 0.17.4 release
This commit is contained in:
Denis Donici 2026-02-18 21:30:17 +02:00 committed by GitHub
parent 553ea1e466
commit bfdae993c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 3161 additions and 130 deletions

View file

@ -5,17 +5,34 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 0.17.4 - 2026-02-18
### Added
- `key` attribute for stable DOM IDs in loops (`name-key`).
- Auto-detect identity from props (`id`, `key`, `index`, `idx`) to generate deterministic IDs.
### Fixed
- Preserve Svelte component instances/local state by avoiding timing-based ID resets and using deterministic ID generation.
- Correct props filtering when `assigns.__changed__` is `nil` (e.g. `~V` sigil / initial render).
### Changed (dev)
- Expanded `example_project` test coverage across **PhoenixTest** (server-side contract) and **Wallaby E2E** (full browser pipeline) to validate LiveView → LiveSvelte hook → Svelte component rendering and interactions across more demos (e.g. counters, slots, live_json, chat, lights, client-side loading, struct/OTP examples).
## 0.17.3 - 2026-02-08
### Added
- Auto-generated IDs for duplicate Svelte components to ensure correct reconciliation
- Upgrade example project to use Daisy UI and latest Phoenix
- Upgrade example project to use Daisy UI and latest Phoenix
### Fixed
- Svelte component remounting on server events when it should update in place
- Static Svelte components in LiveView parent are now handled properly
- Fix duplicate ID collisions in `for` loops by replacing timing-based counter with deterministic identity extraction from props (`id`, `key`, `index`, `idx`). Added `key` attribute for explicit loop identity.
## 0.17.2 - 2026-02-02

View file

@ -92,7 +92,7 @@ _If you're updating from an older version, make sure to check the `CHANGELOG.md`
```elixir
defp deps do
[
{:live_svelte, "~> 0.17.3"}
{:live_svelte, "~> 0.17.4"}
]
end
```
@ -487,6 +487,41 @@ To disable SSR on a specific component, set the `ssr` property to false. Like so
<.svelte name="Example" ssr={false} />
```
### Auto-generated IDs and loops
When the same Svelte component is rendered multiple times (e.g. in a `for` loop),
LiveSvelte automatically generates unique, stable DOM IDs so that LiveView can
correctly reconcile hook elements across re-renders.
**How it works (priority order):**
1. **Explicit `id`** — if you pass `id="my-id"`, that value is used as-is.
2. **Explicit `key`** — if you pass `key={index}`, the DOM id becomes `ComponentName-<key>`.
3. **Auto-detected identity from props** — LiveSvelte inspects the `props` map for
common identity keys (`:id`, `:key`, `:index`, `:idx` and their string equivalents).
When found, the value is appended to the component name to form a deterministic ID.
4. **Counter fallback** — when none of the above apply, a simple per-name counter
produces sequential IDs (`Name`, `Name-1`, `Name-2`, …). This works for
standalone components but is **not** reliable inside comprehensions.
For most loops you already pass an `index` or `id` in your props, so IDs are
generated automatically with no extra work:
```elixir
<%= for {item, index} <- Enum.with_index(@list) do %>
<%!-- index in props → auto-generates ids: "Card-0", "Card-1", … --%>
<.svelte name="Card" props={%{index: index, color: @color}} />
<% end %>
```
If your props don't contain a natural identity key, use the `key` attribute:
```elixir
<%= for {item, index} <- Enum.with_index(@list) do %>
<.svelte name="Chart" key={index} props={%{data: item.data}} />
<% end %>
```
### JSON Library
LiveSvelte uses Erlang/OTP 27's native `:json` module by default for JSON encoding.

View file

@ -7,6 +7,25 @@ To start your Phoenix server:
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
## Testing
- Run all tests: `mix test`
- Run only E2E (browser) tests: `mix test --only e2e`
E2E tests use [Wallaby](https://hexdocs.pm/wallaby) and serve the app from **built** assets (`priv/static`). After changing Svelte (or other frontend) code, rebuild before E2E so tests see your changes:
```bash
mix assets.js && mix test --only e2e
```
Or use the alias (builds assets then runs tests; pass `--only e2e` to run only E2E):
```bash
mix test.e2e --only e2e
```
You need **Chrome** and **ChromeDriver** on your `PATH` for E2E. If Chromedriver is not installed, run `mix test --exclude e2e` to run the rest of the suite.
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
## Learn more

View file

@ -26,7 +26,7 @@
}
},
"../..": {
"version": "0.17.2",
"version": "0.17.4",
"license": "MIT",
"devDependencies": {
"prettier": "3.3.3",

View file

@ -28,11 +28,11 @@
>
<div class="card-body gap-0 p-0 flex flex-col min-h-0 flex-1 sm:h-[520px]">
<div class="px-4 pt-4 pb-2">
<span class="badge badge-ghost badge-sm font-medium text-base-content/70 w-fit">
<span data-testid="chat-user-badge" class="badge badge-ghost badge-sm font-medium text-base-content/70 w-fit">
{name}
</span>
</div>
<ul bind:this={messagesElement} class="flex flex-col gap-3 flex-1 min-h-0 overflow-x-clip overflow-y-auto px-4 py-2">
<ul data-testid="chat-messages" bind:this={messagesElement} class="flex flex-col gap-3 flex-1 min-h-0 overflow-x-clip overflow-y-auto px-4 py-2">
{#each messages as message (message.id)}
{@const me = message.name === name}
<li
@ -40,7 +40,7 @@
class={me ? "chat chat-end" : "chat chat-start"}
>
<div in:fly={{y: 10}} class="chat-header text-xs text-base-content/60">{message.name}</div>
<div class={me ? "chat-bubble bg-brand text-white border-0" : "chat-bubble chat-bubble-neutral"}>
<div data-testid="chat-message" class={me ? "chat-bubble bg-brand text-white border-0" : "chat-bubble chat-bubble-neutral"}>
{message.body}
</div>
</li>
@ -51,6 +51,7 @@
<div class="relative flex-1 min-w-0">
<!-- svelte-ignore a11y_autofocus -->
<input
data-testid="chat-message-input"
type="text"
name="message"
bind:value={body}
@ -64,7 +65,7 @@
{charCount}
</span>
</div>
<button type="submit" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90 shrink-0"> Send </button>
<button data-testid="chat-send" type="submit" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90 shrink-0"> Send </button>
</form>
</div>
</div>

View file

@ -1 +1 @@
<div class="rounded-lg bg-base-200/50 border border-base-300/50 px-3 py-2 text-sm text-base-content/80">This is the component!</div>
<div data-testid="client-side-loading-content" class="rounded-lg bg-base-200/50 border border-base-300/50 px-3 py-2 text-sm text-base-content/80">This is the component!</div>

View file

@ -16,15 +16,17 @@
phx-click="set_number"
value={number - amount}
aria-label="Decrease by {amount}"
data-testid="hybrid-plus-minus-minus"
>
-{amount}
</button>
<span class="text-3xl font-bold tabular-nums text-brand min-w-[3rem] text-center">{number}</span>
<span class="text-3xl font-bold tabular-nums text-brand min-w-[3rem] text-center" data-testid="hybrid-plus-minus-value">{number}</span>
<button
class="btn btn-square btn-sm btn-success border-0"
phx-click="set_number"
value={number + amount}
aria-label="Increase by {amount}"
data-testid="hybrid-plus-minus-plus"
>
+{amount}
</button>
@ -37,6 +39,7 @@
bind:value={amount}
min="1"
aria-label="Step amount"
data-testid="hybrid-plus-minus-step"
/>
</label>
</div>

View file

@ -1 +1 @@
Hello World
<span data-testid="hello-world-content">Hello World</span>

View file

@ -12,7 +12,7 @@
<span class="badge badge-ghost badge-sm font-medium text-base-content/70 w-fit"> Light </span>
<div class="flex justify-between items-center gap-4">
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" class="toggle toggle-brand" checked={isOn} onchange={toggleLight} aria-label="Toggle light" />
<input type="checkbox" class="toggle toggle-brand" checked={isOn} onchange={toggleLight} aria-label="Toggle light" data-testid="light-toggle" />
<span class="text-sm font-medium text-base-content/80">On / Off</span>
</label>
<div class="flex gap-2">
@ -20,6 +20,7 @@
phx-click="down"
class="btn btn-square btn-outline btn-sm border-base-300 hover:border-brand hover:text-brand"
aria-label="Decrease brightness"
data-testid="light-down"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -36,6 +37,7 @@
phx-click="up"
class="btn btn-square btn-outline btn-sm border-base-300 hover:border-brand hover:text-brand"
aria-label="Increase brightness"
data-testid="light-up"
>
<svg
xmlns="http://www.w3.org/2000/svg"

View file

@ -23,7 +23,7 @@
<span class="badge badge-ghost badge-sm font-medium text-base-content/70 w-fit"> Brightness </span>
<progress class="progress progress-brand border-0 w-full rounded-full h-3 bg-base-200" value={$progress} max="1"></progress>
<div class="flex items-center justify-center min-h-10">
<span class="font-mono text-lg font-semibold tabular-nums {brightness > 0 ? 'text-brand' : 'text-base-content/50'}">
<span class="font-mono text-lg font-semibold tabular-nums {brightness > 0 ? 'text-brand' : 'text-base-content/50'}" data-testid="light-brightness-value">
{brightness > 0 ? `${brightness}%` : "OFF"}
</span>
</div>

View file

@ -11,12 +11,12 @@
<dl class="flex flex-col gap-2">
<div class="flex justify-between items-center gap-4 text-sm">
<dt class="text-base-content/60">Key length</dt>
<dd class="font-mono font-semibold tabular-nums text-brand">{keyCount.toLocaleString()}</dd>
<dd data-testid="live-json-key-count" class="font-mono font-semibold tabular-nums text-brand">{keyCount.toLocaleString()}</dd>
</div>
<div class="flex justify-between items-center gap-4 text-sm">
<dt class="text-base-content/60">Rough byte size</dt>
<dd class="font-mono font-semibold tabular-nums text-brand">{byteSize.toLocaleString()}</dd>
<dd data-testid="live-json-byte-size" class="font-mono font-semibold tabular-nums text-brand">{byteSize.toLocaleString()}</dd>
</div>
</dl>
<button phx-click="remove_element" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90 w-fit mt-1"> Remove element </button>
<button data-testid="live-json-remove-element" phx-click="remove_element" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90 w-fit mt-1"> Remove element </button>
</div>

View file

@ -18,13 +18,13 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="flex flex-col gap-1">
<span class="text-xs text-base-content/50 font-medium">Unordered</span>
<pre class="font-mono text-sm bg-base-200/80 text-base-content p-3 rounded-lg border border-base-300/50"><code
<pre data-testid="lodash-unordered" class="font-mono text-sm bg-base-200/80 text-base-content p-3 rounded-lg border border-base-300/50"><code
>[{unordered.join(", ")}]</code
></pre>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-base-content/50 font-medium">Ordered</span>
<pre class="font-mono text-sm bg-base-200/80 text-base-content p-3 rounded-lg border border-base-300/50"><code
<pre data-testid="lodash-ordered" class="font-mono text-sm bg-base-200/80 text-base-content p-3 rounded-lg border border-base-300/50"><code
>[{ordered.join(", ")}]</code
></pre>
</div>

View file

@ -41,6 +41,7 @@
<form class="flex gap-2 flex-wrap">
<input
data-testid="log-list-new-entry"
type="text"
bind:value={body}
class="input input-bordered input-sm flex-1 min-w-0 bg-base-200/50 border-base-300"
@ -54,7 +55,7 @@
{#if showItems}
<div class="border border-base-300/50 rounded-lg bg-base-200/30 overflow-hidden" transition:fly={{x: -20}}>
<ul class="max-h-64 overflow-auto divide-y divide-base-300/50">
<ul data-testid="log-list-items" class="max-h-64 overflow-auto divide-y divide-base-300/50">
{#each items.slice(0, i) as item (item.id)}
<li in:fly={{x: -40}} out:fly={{x: 20}}>
<div transition:slide|local class="px-3 py-2 text-sm font-mono text-base-content/90">
@ -65,7 +66,7 @@
{/each}
</ul>
{#if items.length === 0}
<div class="px-3 py-6 text-center text-sm text-base-content/50">
<div data-testid="log-list-empty-state" class="px-3 py-6 text-center text-sm text-base-content/50">
No entries yet. Add one above or wait for the timer.
</div>
{/if}

View file

@ -113,9 +113,9 @@
<title>Notes ({encoder})</title>
</svelte:head>
<div class="w-full max-w-4xl mx-auto">
<div class="w-full max-w-4xl mx-auto" data-testid="notes-otp-app">
<!-- Info -->
<div class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden mb-6 md:min-w-md">
<div class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden mb-6 md:min-w-md" data-testid="notes-otp-info">
<div class="card-body gap-2 p-4">
<span class="badge badge-ghost badge-sm font-medium text-base-content/70 w-fit">
{encoder} JSON encoder
@ -126,6 +126,7 @@
<!-- Create Note Form -->
<form
data-testid="notes-otp-form"
onsubmit={e => {
e.preventDefault()
handleSubmit()
@ -139,6 +140,7 @@
<span class="text-xs font-medium text-base-content/50">Title *</span>
<input
id="title"
data-testid="notes-otp-title"
type="text"
bind:value={title}
placeholder="Enter note title"
@ -151,6 +153,7 @@
<span class="text-xs font-medium text-base-content/50">Content</span>
<textarea
id="content"
data-testid="notes-otp-content"
bind:value={content}
placeholder="Enter note content (optional)"
rows="3"
@ -177,17 +180,18 @@
</div>
</div>
<button type="submit" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90 w-fit"> Add note </button>
<button type="submit" data-testid="notes-otp-submit" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90 w-fit"> Add note </button>
</div>
</form>
<!-- Notes Grid -->
<ul class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<ul class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" data-testid="notes-otp-list">
{#each notes as note, index (note.id)}
<li
animate:flip={{delay: 100, duration: 500}}
role="listitem"
id={`note-${note.id}`}
data-testid="notes-otp-note"
aria-label={`Note ${index + 1}`}
class="rounded-lg border border-base-300/50 p-4 transition-shadow hover:shadow-md"
style="background-color: {note.color}"
@ -195,7 +199,9 @@
<div class="flex justify-between items-start gap-2 mb-2">
<h3 class="font-semibold text-base-content break-words flex-1 min-w-0">{note.title}</h3>
<button
type="button"
aria-label="Delete note"
data-testid="notes-otp-delete"
onclick={() => handleDelete(note.id)}
class="btn btn-ghost btn-xs hover:bg-error/20 hover:text-error shrink-0"
title="Delete note"
@ -216,7 +222,7 @@
</div>
</li>
{:else}
<li class="col-span-full">
<li class="col-span-full" data-testid="notes-otp-empty">
<div class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden">
<div class="card-body py-12 text-center">
<p class="text-base-content/70 font-medium">No notes yet</p>

View file

@ -7,7 +7,7 @@
<div class="flex flex-col justify-center items-center gap-4 p-4">
<h2 class="text-center text-2xl font-light my-4">Plus / Minus</h2>
<p class="text-sm text-base-content/50 text-center max-w-sm">Regular non LiveView Svelte component with client-side state.</p>
<div class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden w-full md:min-w-md">
<div class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden w-full md:min-w-md max-w-md">
<div class="card-body gap-4 p-5">
<span class="badge badge-ghost badge-sm font-medium text-base-content/70 w-fit"> Value </span>
<div class="flex flex-row items-center justify-center gap-6 py-2">
@ -15,14 +15,16 @@
class="btn btn-square btn-sm btn-outline border-base-300 hover:border-error hover:text-error"
onclick={() => (number -= amount)}
aria-label="Decrease by {amount}"
data-testid="plus-minus-minus"
>
-{amount}
</button>
<span class="text-3xl font-bold tabular-nums text-brand min-w-[3rem] text-center">{number}</span>
<span class="text-3xl font-bold tabular-nums text-brand min-w-[3rem] text-center" data-testid="plus-minus-value">{number}</span>
<button
class="btn btn-square btn-sm btn-success border-0"
onclick={() => (number += amount)}
aria-label="Increase by {amount}"
data-testid="plus-minus-plus"
>
+{amount}
</button>

View file

@ -1,7 +1,7 @@
<script>
/** @type {{number: any}} */
let {number} = $props()
let other = $state(1)
/** @type {{number: any, initialClientValue?: number}} */
let { number, initialClientValue = 1 } = $props()
let other = $state(initialClientValue)
</script>
<svelte:head>
@ -25,8 +25,8 @@
<div class="card bg-base-100 shadow-lg border border-base-300/50 w-52 md:min-w-md">
<div class="card-body items-center text-center gap-4 py-6">
<span class="badge badge-outline badge-sm font-medium text-base-content/70">Client</span>
<span class="text-4xl font-bold tabular-nums text-success">{other}</span>
<button class="btn btn-sm btn-success border-0" onclick={() => (other += 1)}> +1 </button>
<span data-testid="simple-counter-client-value" class="text-4xl font-bold tabular-nums text-success">{other}</span>
<button data-testid="simple-counter-client-increment" class="btn btn-sm btn-success border-0" onclick={() => (other += 1)}> +1 </button>
</div>
</div>
</div>

View file

@ -6,23 +6,23 @@
let {children, subtitle}: Props = $props()
</script>
<div class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden w-full max-w-md md:min-w-md">
<div data-testid="slots-card" class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden w-full max-w-md md:min-w-md">
<div class="card-body gap-4 p-5">
<span class="badge badge-ghost badge-sm font-medium text-base-content/70 w-fit"> Slots </span>
<div class="text-sm text-base-content/50 italic">Opening</div>
<div class="border-t border-base-300/50 pt-2">
<span data-testid="slots-badge" class="badge badge-ghost badge-sm font-medium text-base-content/70 w-fit"> Slots </span>
<div data-testid="slots-opening" class="text-sm text-base-content/50 italic">Opening</div>
<div data-testid="slots-default-content" class="border-t border-base-300/50 pt-2">
<div class="text-base-content/90">
{@render children?.()}
</div>
</div>
{#if subtitle}
<div class="border-t border-base-300/50 pt-3 space-y-2">
<div data-testid="slots-subtitle" class="border-t border-base-300/50 pt-3 space-y-2">
<h3 class="text-lg font-semibold text-base-content/80">Svelte subtitle</h3>
<div class="text-base-content/90">
{@render subtitle?.()}
</div>
</div>
{/if}
<div class="border-t border-base-300/50 pt-2 text-sm text-base-content/50 italic">Closing</div>
<div data-testid="slots-closing" class="border-t border-base-300/50 pt-2 text-sm text-base-content/50 italic">Closing</div>
</div>
</div>

View file

@ -0,0 +1,29 @@
<script>
/**
* @type {{ color: string, index: number }}
*/
let { color, index } = $props();
const colorClass = $derived(
color === "red" ? "text-red-500" : color === "blue" ? "text-blue-500" : "text-base-content/80"
);
const borderClass = $derived(
color === "red" ? "border-red-500" : color === "blue" ? "border-blue-500" : "border-base-content/30"
);
</script>
<div class="card card-xs bg-base-100 shadow-md border overflow-hidden {borderClass}">
<div class="card-body gap-4 p-5">
<span class="badge badge-outline badge-sm font-medium text-base-content/70 w-fit">
LiveSvelte (file based)
</span>
<h3 class="font-semibold text-lg text-base-content">Svelte component {index}</h3>
<p class="text-sm text-base-content/80">
This svelte component will receive the color from the LiveView and display it.
</p>
<div
class={colorClass}
>
<span class="font-medium italic uppercase" data-testid="static-color-svelte-value">{color}</span>
</div>
</div>
</div>

View file

@ -0,0 +1,16 @@
<script>
let { color, index } = $props();
console.log(color);
</script>
<div
class:bg-white={color === "white"}
class:border-[var(--color-brand)]={color === "white"}
class:border-red-500={color === "red"}
class:bg-red-500={color === "red"}
class:text-white={color === "red"}
class="flex flex-col justify-center items-center p-4 w-100 border-1 rounded-md shadow-lg"
>
<div class="font-bold p-1 text-lg">{index}</div>
<div class="font-bold p-1 text-lg">Svelte component</div>
</div>

View file

@ -1,13 +1,13 @@
<script>
let {struct} = $props()
let {struct} = $props();
</script>
<div class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden md:min-w-md">
<div class="card-body gap-4 p-5">
<span class="badge badge-ghost badge-sm font-medium text-base-content/70 w-fit"> Struct </span>
<pre class="font-mono text-sm bg-base-200/80 text-base-content p-4 rounded-lg border border-base-300/50 overflow-x-auto"><code
<pre data-testid="struct-json" class="font-mono text-sm bg-base-200/80 text-base-content p-4 rounded-lg border border-base-300/50 overflow-x-auto"><code
>{JSON.stringify(struct, null, 2)}</code
></pre>
<button phx-click="randomize" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90 w-fit"> Randomize Age </button>
<button phx-click="randomize" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90 w-fit" data-testid="struct-randomize-age"> Randomize Age </button>
</div>
</div>

View file

@ -15,7 +15,17 @@ config :example, Example.Repo,
config :example, ExampleWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "XvZC2gcAePazgtLhJ+/kX9NxRrJA9HBGVxRjfVHW7f//XBDiDpQJ4/ot6N3llQjW",
server: false
server: true
# Wallaby E2E: base URL must match endpoint port
config :wallaby, base_url: "http://localhost:4002"
# PhoenixTest: lightweight server-side testing (no browser needed)
config :phoenix_test, :endpoint, ExampleWeb.Endpoint
# Disable LiveSvelte SSR in test so E2E tests run against the client-side bundle.
# Otherwise server-rendered HTML can mask client-only bugs (e.g. hardcoded props).
config :live_svelte, ssr: false
# In test we don't send emails.
config :example, Example.Mailer, adapter: Swoosh.Adapters.Test

View file

@ -22,7 +22,8 @@ defmodule ExampleWeb.Layouts do
%{label: "Sigil", to: ~p"/live-sigil"},
%{label: "Plus/Minus (Static)", to: ~p"/plus-minus-svelte"},
%{label: "Plus/Minus (Live)", to: ~p"/live-plus-minus"},
%{label: "Hybrid Counter", to: ~p"/live-plus-minus-hybrid"}
%{label: "Hybrid Counter", to: ~p"/live-plus-minus-hybrid"},
%{label: "Static + List", to: ~p"/live-static-color"}
]
},
%{

View file

@ -90,6 +90,12 @@
</a>
<span class="text-base-content/50 text-sm">- Client + server events</span>
</li>
<li>
<a href={~p"/live-static-color"} class="link link-primary">
Static + List
</a>
<span class="text-base-content/50 text-sm">- Svelte component with dynamic list</span>
</li>
</ul>
</div>
</div>

View file

@ -13,6 +13,11 @@ defmodule ExampleWeb.Endpoint do
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
# In test, prevent caching of assets so E2E always loads the latest app.js after mix assets.js
if Mix.env() == :test do
plug :no_cache_assets
end
# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phx.digest
@ -48,4 +53,14 @@ defmodule ExampleWeb.Endpoint do
plug Plug.Head
plug Plug.Session, @session_options
plug ExampleWeb.Router
defp no_cache_assets(conn, _opts) do
Plug.Conn.register_before_send(conn, fn c ->
if String.starts_with?(c.request_path, "/assets/") do
Plug.Conn.put_resp_header(c, "cache-control", "no-store, no-cache, must-revalidate")
else
c
end
end)
end
end

View file

@ -98,6 +98,7 @@ defmodule ExampleWeb.LiveBreakingNews do
<form class="flex flex-wrap gap-2">
<input
data-testid="breaking-news-new-headline"
class="input input-bordered input-sm flex-1 min-w-0 bg-base-200/50 border-base-300"
type="text"
bind:value={newItem}
@ -116,7 +117,7 @@ defmodule ExampleWeb.LiveBreakingNews do
</div>
<div class="border border-base-300/50 rounded-lg bg-base-200/30 overflow-hidden">
<ul class="max-h-48 overflow-auto divide-y divide-base-300/50">
<ul data-testid="breaking-news-headlines" class="max-h-48 overflow-auto divide-y divide-base-300/50">
{#each news as item (item.id)}
<li in:fly={{y: 20}} out:slide={{y: -20}} class="flex items-center justify-between gap-2 px-3 py-2 text-sm">
<span class="min-w-0 flex-1 truncate">{item.body}</span>
@ -137,6 +138,8 @@ defmodule ExampleWeb.LiveBreakingNews do
</div>
<div
bind:this={marqueeEl}
data-testid="breaking-news-ticker"
data-rate={speed}
class="fixed bottom-0 w-screen text-white font-bold text-lg py-2 bg-error/90 shadow-inner"
aria-hidden="true"
>

View file

@ -15,7 +15,7 @@ defmodule ExampleWeb.LiveChat do
Enter your name to join; then send messages. Your name labels your bubbles.
</p>
<form :if={!@name} phx-submit="set_name" class="w-full max-w-md">
<form :if={!@name} phx-submit="set_name" class="w-full max-w-md" data-testid="chat-join-form">
<div class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden">
<div class="card-body gap-4 p-5">
<span class="badge badge-ghost badge-sm font-medium text-base-content/70 w-fit">
@ -24,6 +24,7 @@ defmodule ExampleWeb.LiveChat do
<div class="flex gap-2 flex-wrap">
<!-- svelte-ignore a11y-autofocus -->
<input
data-testid="chat-join-name"
type="text"
placeholder="Your name"
name="name"

View file

@ -4,7 +4,7 @@ defmodule ExampleWeb.LiveClientSideLoading do
def render(assigns) do
~H"""
<div class="flex flex-col justify-center items-center gap-6 p-6">
<h2 class="text-center text-2xl font-light my-4">
<h2 data-testid="client-side-loading-heading" class="text-center text-2xl font-light my-4">
Client-side loading
</h2>
<p class="text-sm text-base-content/50 text-center max-w-md">
@ -12,7 +12,7 @@ defmodule ExampleWeb.LiveClientSideLoading do
</p>
<div class="flex flex-col sm:flex-row flex-wrap justify-center gap-6 w-full max-w-3xl">
<section class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden w-full max-w-sm">
<section data-testid="client-side-loading-client-section" class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden w-full max-w-sm">
<div class="card-body gap-4 p-5">
<span class="badge badge-ghost badge-sm font-medium text-base-content/70 w-fit">
Client side
@ -29,7 +29,7 @@ defmodule ExampleWeb.LiveClientSideLoading do
</div>
</section>
<section class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden w-full max-w-sm border-warning/50">
<section data-testid="client-side-loading-server-section" class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden w-full max-w-sm border-warning/50">
<div class="card-body gap-4 p-5">
<span class="badge badge-warning badge-sm font-medium w-fit">
Server side (avoid)

View file

@ -25,7 +25,7 @@ defmodule ExampleWeb.LiveNotesOtp do
def render(assigns) do
~H"""
<div class="flex flex-col justify-center items-center gap-6 p-6">
<h2 class="text-center text-2xl font-light my-4">
<h2 data-testid="notes-otp-heading" class="text-center text-2xl font-light my-4">
Notes (OTP)
</h2>
<p class="text-sm text-base-content/50 text-center max-w-md">

View file

@ -20,14 +20,16 @@ defmodule ExampleWeb.LivePlusMinus do
class="btn btn-square btn-sm btn-outline border-base-300 hover:border-error hover:text-error"
phx-click="subtract"
aria-label={"Decrease by #{@amount}"}
data-testid="live-plus-minus-minus"
>
-<%= @amount %>
</button>
<span class="text-3xl font-bold tabular-nums text-brand min-w-[3rem] text-center"><%= @number %></span>
<span class="text-3xl font-bold tabular-nums text-brand min-w-[3rem] text-center" data-testid="live-plus-minus-value"><%= @number %></span>
<button
class="btn btn-square btn-sm btn-success border-0"
phx-click="add"
aria-label={"Increase by #{@amount}"}
data-testid="live-plus-minus-plus"
>
+<%= @amount %>
</button>
@ -40,6 +42,7 @@ defmodule ExampleWeb.LivePlusMinus do
value={@amount}
min="1"
name="amount"
phx-blur="update_amount"
phx-keydown="update_amount"
phx-keyup="update_amount"
aria-label="Step amount"
@ -63,7 +66,20 @@ defmodule ExampleWeb.LivePlusMinus do
{:noreply, assign(socket, :number, socket.assigns.number + socket.assigns.amount)}
end
def handle_event("update_amount", %{"value" => amount_str}, socket) do
{:noreply, assign(socket, :amount, String.to_integer(amount_str))}
def handle_event("update_amount", %{"value" => value}, socket) do
amount_str = extract_amount_value(value)
amount = if is_binary(amount_str) and amount_str != "" do
case Integer.parse(amount_str) do
{n, _} when n >= 1 -> n
_ -> socket.assigns.amount
end
else
socket.assigns.amount
end
{:noreply, assign(socket, :amount, amount)}
end
defp extract_amount_value(str) when is_binary(str), do: str
defp extract_amount_value(%{"value" => v}), do: extract_amount_value(v)
defp extract_amount_value(_), do: ""
end

View file

@ -25,17 +25,17 @@ defmodule ExampleWeb.LiveSigil do
Server + Client
</span>
<div class="font-mono text-center text-lg tabular-nums">
<span class="text-brand">{number}</span>
<span data-testid="sigil-server-number" class="text-brand">{number}</span>
<span class="text-base-content/60"> + </span>
<span class="text-success">{number2}</span>
<span data-testid="sigil-client-number" class="text-success">{number2}</span>
<span class="text-base-content/60"> = </span>
<span class="font-bold text-brand">{combined}</span>
<span data-testid="sigil-combined" class="font-bold text-brand">{combined}</span>
</div>
<div class="flex gap-2 justify-center pt-2">
<button class="btn btn-sm bg-brand text-white border-0 hover:opacity-90" phx-click="increment">
<button data-testid="sigil-increment-server" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90" phx-click="increment">
+server
</button>
<button class="btn btn-sm btn-success border-0" onclick={() => number2 += 1}>
<button data-testid="sigil-increment-client" class="btn btn-sm btn-success border-0" onclick={() => number2 += 1}>
+client
</button>
</div>

View file

@ -19,8 +19,8 @@ defmodule ExampleWeb.LiveSimpleCounter do
LiveView (native)
</span>
<div class="flex flex-row items-center justify-center gap-6 py-2">
<span class="text-4xl font-bold tabular-nums text-brand"><%= @number %></span>
<button class="btn btn-sm bg-brand text-white border-0 hover:opacity-90" phx-click="increment">
<span data-testid="live-simple-counter-value" class="text-4xl font-bold tabular-nums text-brand"><%= @number %></span>
<button data-testid="live-simple-counter-increment" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90" phx-click="increment">
+1
</button>
</div>
@ -35,11 +35,11 @@ defmodule ExampleWeb.LiveSimpleCounter do
<div class="flex flex-wrap gap-6 justify-center py-4">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-base-content/50">Component 1</span>
<.svelte name="SimpleCounter" props={%{number: @number}} socket={@socket} />
<.svelte name="SimpleCounter" props={%{number: @number, initialClientValue: @initial_client_value}} socket={@socket} />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-base-content/50">Component 2</span>
<.svelte name="SimpleCounter" props={%{number: @number}} socket={@socket} />
<.svelte name="SimpleCounter" props={%{number: @number, initialClientValue: @initial_client_value}} socket={@socket} />
</div>
</div>
</div>
@ -51,7 +51,13 @@ defmodule ExampleWeb.LiveSimpleCounter do
end
def mount(_session, _params, socket) do
{:ok, assign(socket, :number, 10)}
initial_client =
Application.get_env(:example, :simple_counter_initial_client_value, 1)
{:ok,
socket
|> assign(:number, 10)
|> assign(:initial_client_value, initial_client)}
end
def handle_event("increment", _values, socket) do

View file

@ -12,14 +12,14 @@ defmodule ExampleWeb.LiveSlotsDynamic do
</p>
<.svelte name="Slots" socket={@socket}>
<div class="flex flex-wrap items-center gap-3">
<button phx-click="increase" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90">
<button data-testid="slots-dynamic-increment" phx-click="increase" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90">
Increment the number
</button>
<span class="text-2xl font-bold tabular-nums text-brand"><%= @number %></span>
<span data-testid="slots-dynamic-number" class="text-2xl font-bold tabular-nums text-brand"><%= @number %></span>
</div>
<:subtitle>
<span class="text-xl font-semibold tabular-nums text-brand"><%= @number %></span>
<span data-testid="slots-dynamic-subtitle-number" class="text-xl font-semibold tabular-nums text-brand"><%= @number %></span>
</:subtitle>
</.svelte>
</div>

View file

@ -0,0 +1,116 @@
defmodule ExampleWeb.LiveStaticColor do
use ExampleWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, socket |> assign(:list, ["element", "element", "element"]) |> assign(:color, "white")}
end
@impl true
def render(assigns) do
~H"""
<h1 class="text-center text-2xl font-light my-4">
Static Color Demo
</h1>
<p class="text-sm text-base-content/50 mb-8 text-center">
Passing dynamic props to a list of Svelte components from LiveView.
</p>
<div class="border-1 border-[var(--color-brand)] shadow-lg card p-5">
<div class="badge badge-outline badge-sm font-medium text-base-content/70 w-fit">LiveView</div>
<div class="flex flex-row my-4 justify-center items-center">
<div class="flex flex-col justify-center items-center gap-4">
<div>
<div class="w-full border-1 border-gray-500 card card-lg p-5 flex flex-col gap-5 justify-center items-center">
<div class="badge badge-outline badge-sm font-medium text-base-content/70 text-nowrap">LiveView Component</div>
<div class="flex flex-col md:flex-row gap-4 items-center">
<button class="btn bg-slate-50" phx-click="change_color_to_white">
Change color to white
</button>
<button class="btn bg-red-500 text-white" phx-click="change_color_to_red">
Change color to red
</button>
<button class="btn bg-[var(--color-brand)] text-white" phx-click="add_element">Add Element</button>
</div>
<div class="text-sm text-base-content/50 text-nowrap">Total elements: <span class="font-bold text-lg">{length(@list)}</span></div>
</div>
</div>
<h3 class="my-4 text-base-content">Use LiveSvelte via a file based component (Static.svelte)</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<%= for {_item, index} <- Enum.with_index(@list) do %>
<.svelte name="Static" props={%{color: @color, index: index}} />
<% end %>
</div>
<div class="divider"></div>
<h3 class="mb-4 text-base-content">Use LiveSvelte as a function via the (~V sigil) to render the Svelte component</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<%= for {_item, index} <- Enum.with_index(@list) do %>
<.static_svelte_component color={@color} index={index} />
<% end %>
</div>
</div>
</div>
</div>
"""
end
@impl true
def handle_params(params, _uri, socket) do
# Using a case statement is often cleaner than multiple defs
case params do
%{"color" => color} ->
{:noreply, assign(socket, :color, color)}
_ ->
{:noreply, assign(socket, :color, "white")}
end
end
@impl true
def handle_event("add_element", _params, socket) do
# Appending to the list
new_list = socket.assigns.list ++ ["element"]
{:noreply, assign(socket, list: new_list)}
end
def handle_event("change_color_to_white", _params, socket) do
{:noreply, assign(socket, color: "white")}
end
def handle_event("change_color_to_red", _params, socket) do
{:noreply, assign(socket, color: "red")}
end
def static_svelte_component(assigns) do
~V"""
<script>
/**
* @type {{ color: string, index: number }}
*/
let { color, index } = $props();
const colorClass = $derived(
color === "red" ? "text-red-500" : color === "blue" ? "text-blue-500" : "text-base-content/80"
);
const borderClass = $derived(
color === "red" ? "border-red-500" : color === "blue" ? "border-blue-500" : "border-base-content/30"
);
</script>
<div class="card card-xs bg-base-100 shadow-md border overflow-hidden {borderClass}">
<div class="card-body gap-4 p-5">
<span class="badge badge-outline badge-sm font-medium text-base-content/70 w-fit">
LiveSvelte (~V sigil)
</span>
<h3 class="font-semibold text-lg text-base-content">Svelte component {index}</h3>
<p class="text-sm text-base-content/80">
This svelte component will receive the color from the LiveView and display it.
</p>
<div
class={colorClass}
>
<span class="font-medium italic uppercase" data-testid="static-color-svelte-value">{color}</span>
</div>
</div>
</div>
"""
end
end

View file

@ -26,6 +26,7 @@ defmodule ExampleWeb.LiveStruct do
end
def handle_event("randomize", _, socket) do
{:noreply, assign(socket, :struct, %User{name: "Bob", age: Enum.random(0..100)})}
new_struct = %User{name: "Bob", age: Enum.random(0..100)}
{:noreply, assign(socket, :struct, new_struct)}
end
end

View file

@ -28,6 +28,7 @@ defmodule ExampleWeb.Router do
get "/plus-minus-svelte", PageController, :plus_minus_svelte
live "/live-plus-minus", LivePlusMinus
live "/live-plus-minus-hybrid", LivePlusMinusHybrid
live "/live-static-color", LiveStaticColor
live "/live-log-list", LiveLogList
live "/live-breaking-news", LiveBreakingNews
live "/live-chat", LiveChat

View file

@ -37,8 +37,8 @@ defmodule Example.MixProject do
{:gettext, "~> 0.20"},
{:json_diff_ex, "~> 0.6", override: true},
{:live_json, "~> 0.4.5"},
# {:live_svelte, path: ".."},
{:live_svelte, "~> 0.17.3"},
{:live_svelte, path: ".."},
# {:live_svelte, "~> 0.17.4"},
{:phoenix, "~> 1.8.0"},
{:phoenix_ecto, "~> 4.4"},
{:phoenix_html, "~> 4.1"},
@ -50,6 +50,8 @@ defmodule Example.MixProject do
{:telemetry_metrics, "~> 0.6"},
{:telemetry_poller, "~> 1.0"},
{:floki, ">= 0.30.0", only: :test},
{:wallaby, "~> 0.30", runtime: false, only: :test},
{:phoenix_test, "~> 0.9", only: :test},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev}
]
@ -67,6 +69,8 @@ defmodule Example.MixProject do
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
"assets.js": ["cmd --cd assets node build.js"],
"test.e2e": ["assets.js", "ecto.create --quiet", "ecto.migrate --quiet", "test"],
"assets.setup": ["tailwind.install --if-missing"],
"assets.build": ["tailwind default"],
"assets.deploy": [

View file

@ -1,6 +1,7 @@
%{
"bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
@ -10,17 +11,24 @@
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"exqlite": {:hex, :exqlite, "0.34.0", "ebca3570eb4c4eb4345d76c8e44ce31a62de7b24a54fd118164480f2954bd540", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "bcdc58879a0db5e08cd5f6fbe07a0692ceffaaaa617eab46b506137edf0a2742"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"httpoison": {:hex, :httpoison, "2.3.0", "10eef046405bc44ba77dc5b48957944df8952cc4966364b3cf6aa71dce6de587", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d388ee70be56d31a901e333dbcdab3682d356f651f93cf492ba9f06056436a2c"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"json_diff_ex": {:hex, :json_diff_ex, "0.7.0", "ef9a7809fce09fecc7fe4ffcac85658ab6de6aaccd55e056dea16da4ecfa6121", [:mix], [], "hexpm", "be71212b736f0f36ef5d805c7d72de4d4e57b5176d0cca99d78eb4d8198da547"},
"jsonpatch": {:hex, :jsonpatch, "0.13.1", "fd32eae78e2a7d9a2f40ee2468d22b75676b5d49bb5f6f929204123b15e3f254", [:make, :mix], [], "hexpm", "b3a29d2a3d56149e50fd5a8e19f5dd82c7f85491871b5598405cc7fb97de8f0b"},
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
"live_json": {:hex, :live_json, "0.4.5", "7a97932a8bb944d546a2e0af231aa90889eeaa0ab3f77afc50748636d9202581", [:mix], [{:jason, ">= 1.3.0", [hex: :jason, repo: "hexpm", optional: true]}, {:json_diff_ex, "~> 0.5.0", [hex: :json_diff_ex, repo: "hexpm", optional: false]}, {:jsonpatch, "~> 0.13.1", [hex: :jsonpatch, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 3.1.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.16.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "43f941c94ef3a917f0940552316ae23128b8da274511d1d356494441a69edd71"},
"live_svelte": {:hex, :live_svelte, "0.17.2", "b644a22c96e44e03491240e6302fc21cae0c66e2b702531cbbd4732a9f08cc1d", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}, {:nodejs, "~> 3.1", [hex: :nodejs, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 3.3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "17ddf9bca37ddc8e1090dad267cc81f846a8ad8647864e6172fb9fcdd7a0c066"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
"nodejs": {:hex, :nodejs, "3.1.3", "8693fae9fbefa14fb99329292c226df4d4711acfa5a3fa4182dd8d3f779b30bf", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5.1", [hex: :poolboy, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.7", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e7751aad77ac55f8e6c5c07617378afd88d2e0c349d9db2ebb5273aae46ef6a9"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
@ -29,6 +37,7 @@
"phoenix_live_view": {:hex, :phoenix_live_view, "1.0.18", "943431edd0ef8295ffe4949f0897e2cb25c47d3d7ebba2b008d7c68598b887f1", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "724934fd0a68ecc57281cee863674454b06163fed7f5b8005b5e201ba4b23316"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_test": {:hex, :phoenix_test, "0.9.1", "ac58a4d341c594ac57ce52a6ce643200084fad419a91b72896a44881fe84809c", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:lazy_html, "~> 0.1.7", [hex: :lazy_html, repo: "hexpm", optional: false]}, {:mime, ">= 1.0.0", [hex: :mime, repo: "hexpm", optional: true]}, {:phoenix, ">= 1.7.10", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "ed453394c0f8987aa58a06e2302e7dd4bc53cd2d25eff5a18c4a5775241ebe61"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
@ -38,8 +47,11 @@
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"tesla": {:hex, :tesla, "1.16.0", "de77d083aea08ebd1982600693ff5d779d68a4bb835d136a0394b08f69714660", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "eb3bdfc0c6c8a23b4e3d86558e812e3577acff1cb4acb6cfe2da1985a1035b89"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"wallaby": {:hex, :wallaby, "0.30.12", "1736bad495b222053a04307e22075bf3a38e7978363294d1eacdea1417bbb208", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:httpoison, "~> 0.12 or ~> 1.0 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_ecto, ">= 3.0.0", [hex: :phoenix_ecto, repo: "hexpm", optional: true]}, {:web_driver_client, "~> 0.2.0", [hex: :web_driver_client, repo: "hexpm", optional: false]}], "hexpm", "03b33244213b53af7c7e354a6ce129b1ffec9c57cac613eb76543f1fd46fcf70"},
"web_driver_client": {:hex, :web_driver_client, "0.2.0", "63b76cd9eb3b0716ec5467a0f8bead73d3d9612e63f7560d21357f03ad86e31a", [:mix], [{:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.3", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "83cc6092bc3e74926d1c8455f0ce927d5d1d36707b74d9a65e38c084aab0350f"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
}

View file

@ -3,6 +3,6 @@ defmodule ExampleWeb.PageControllerTest do
test "GET /", %{conn: conn} do
conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
assert html_response(conn, 200) =~ "LiveSvelte Examples"
end
end

View file

@ -0,0 +1,16 @@
defmodule ExampleWeb.HelloWorldTest do
use ExampleWeb.FeatureCase, async: false
@moduledoc """
E2E test for the /hello-world page (PageController + HelloWorld Svelte component).
"""
@moduletag :e2e
test "hello-world page loads and shows HelloWorld Svelte content", %{session: session} do
session = visit(session, "/hello-world")
# Svelte component mounts into data-svelte-target; find by testid so we wait for client-side mount
el = session |> find(Query.css("[data-testid='hello-world-content']"))
assert Wallaby.Element.text(el) == "Hello World"
end
end

View file

@ -0,0 +1,116 @@
defmodule ExampleWeb.LiveBreakingNewsTest do
@moduledoc """
E2E test for the LiveBreakingNews LiveView (/live-breaking-news).
Validates that the page mounts with initial headlines, adding a headline via the UI
works, and removing a headline removes it from the list.
"""
use ExampleWeb.FeatureCase, async: false
@moduletag :e2e
defp headline_items(session) do
session |> all(Query.css("[data-testid='breaking-news-headlines'] li"))
end
defp wait_for_headline_with_text(session, text, attempts \\ 50) do
items = headline_items(session)
found = Enum.any?(items, fn el -> Wallaby.Element.text(el) =~ text end)
cond do
found -> session
attempts == 0 -> raise "timeout waiting for headline containing #{inspect(text)}"
true ->
:timer.sleep(100)
wait_for_headline_with_text(session, text, attempts - 1)
end
end
test "page mounts and shows heading, input, and initial headlines", %{session: session} do
session
|> visit("/live-breaking-news")
|> assert_has(Query.css("h2", text: "Breaking News"))
|> assert_has(Query.css("p", text: "Add headlines and control the ticker speed; remove items from the list."))
|> assert_has(Query.css("[data-testid='breaking-news-new-headline']"))
# Initial news has 5 items; at least one visible
items = headline_items(session)
assert length(items) >= 1
assert Enum.any?(items, fn el -> Wallaby.Element.text(el) =~ "Giant Pink Elephant" end)
end
test "adding a headline via the UI shows it in the list and clears the input", %{session: session} do
session =
session
|> visit("/live-breaking-news")
|> fill_in(Query.css("[data-testid='breaking-news-new-headline']"), with: "Test headline")
|> click(Query.button("Add"))
session = wait_for_headline_with_text(session, "Test headline")
items = headline_items(session)
assert Enum.any?(items, fn el -> Wallaby.Element.text(el) =~ "Test headline" end)
input = session |> find(Query.css("[data-testid='breaking-news-new-headline']"))
assert Wallaby.Element.attr(input, "value") == ""
end
test "removing a headline removes it from the list", %{session: session} do
session = visit(session, "/live-breaking-news")
session = wait_for_headline_with_text(session, "Giant Pink Elephant")
count_before = headline_items(session) |> length()
# Click the first Remove button (first headline)
remove_btns = session |> all(Query.css("[data-testid='breaking-news-headlines'] li button"))
assert length(remove_btns) >= 1
Wallaby.Element.click(Enum.at(remove_btns, 0))
# Wait for one item to disappear
session = wait_for_headline_removed(session, "Giant Pink Elephant", 30)
count_after = headline_items(session) |> length()
assert count_after == count_before - 1
end
test "faster/slower buttons update ticker rate", %{session: session} do
session =
session
|> visit("/live-breaking-news")
|> assert_has(Query.css("[data-testid='breaking-news-ticker']"))
ticker = session |> find(Query.css("[data-testid='breaking-news-ticker']"))
assert Wallaby.Element.attr(ticker, "data-rate") == "-150"
session = session |> click(Query.button("← Faster"))
session = wait_for_ticker_rate(session, "-170")
session = session |> click(Query.button("Slower →"))
_session = wait_for_ticker_rate(session, "-150")
end
defp wait_for_headline_removed(session, text, attempts) do
items = headline_items(session)
found = Enum.any?(items, fn el -> Wallaby.Element.text(el) =~ text end)
cond do
not found -> session
attempts == 0 -> raise "timeout waiting for headline #{inspect(text)} to be removed"
true ->
:timer.sleep(100)
wait_for_headline_removed(session, text, attempts - 1)
end
end
defp wait_for_ticker_rate(session, expected, attempts \\ 30) do
ticker = session |> find(Query.css("[data-testid='breaking-news-ticker']"))
case Wallaby.Element.attr(ticker, "data-rate") do
^expected ->
session
_ ->
if attempts == 0 do
actual = Wallaby.Element.attr(ticker, "data-rate")
raise "timeout waiting for ticker rate (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
end
:timer.sleep(50)
wait_for_ticker_rate(session, expected, attempts - 1)
end
end
end

View file

@ -0,0 +1,70 @@
defmodule ExampleWeb.LiveChatTest do
@moduledoc """
E2E test for the LiveChat LiveView (/live-chat).
Validates that the page mounts with the join form, joining shows the chat UI,
and sending a message displays it in the chat.
"""
use ExampleWeb.FeatureCase, async: false
@moduletag :e2e
defp chat_bubbles(session) do
session |> all(Query.css("[data-testid='chat-message']"))
end
defp wait_for_chat_bubble_with_text(session, text, attempts \\ 50) do
bubbles = chat_bubbles(session)
found = Enum.any?(bubbles, fn el -> Wallaby.Element.text(el) =~ text end)
cond do
found -> session
attempts == 0 -> raise "timeout waiting for chat bubble containing #{inspect(text)}"
true ->
:timer.sleep(100)
wait_for_chat_bubble_with_text(session, text, attempts - 1)
end
end
test "page mounts and shows heading and join form when not joined", %{session: session} do
session
|> visit("/live-chat")
|> assert_has(Query.css("h2", text: "Chat"))
|> assert_has(Query.css("p", text: "Enter your name to join; then send messages. Your name labels your bubbles."))
|> assert_has(Query.css("[data-testid='chat-join-name']"))
|> assert_has(Query.css("[data-testid='chat-join-form'] button", text: "Join"))
end
test "joining with a name shows chat UI with message input", %{session: session} do
session =
session
|> visit("/live-chat")
|> fill_in(Query.css("[data-testid='chat-join-name']"), with: "Alice")
|> click(Query.css("[data-testid='chat-join-form'] button", text: "Join"))
session
|> assert_has(Query.css("[data-testid='chat-message-input']"))
|> assert_has(Query.css("[data-testid='chat-send']"))
|> assert_has(Query.css("[data-testid='chat-user-badge']", text: "Alice"))
end
test "sending a message shows it in the chat", %{session: session} do
session =
session
|> visit("/live-chat")
|> fill_in(Query.css("[data-testid='chat-join-name']"), with: "Bob")
|> click(Query.css("[data-testid='chat-join-form'] button", text: "Join"))
session
|> assert_has(Query.css("[data-testid='chat-message-input']"))
|> fill_in(Query.css("[data-testid='chat-message-input']"), with: "Hello from E2E")
|> click(Query.css("[data-testid='chat-send']"))
session = wait_for_chat_bubble_with_text(session, "Hello from E2E")
bubbles = chat_bubbles(session)
assert length(bubbles) >= 1
assert Enum.any?(bubbles, fn el -> Wallaby.Element.text(el) =~ "Hello from E2E" end)
# Input cleared after send
input = session |> find(Query.css("[data-testid='chat-message-input']"))
assert Wallaby.Element.attr(input, "value") == ""
end
end

View file

@ -0,0 +1,60 @@
defmodule ExampleWeb.LiveClientSideLoadingTest do
@moduledoc """
E2E test for the LiveClientSideLoading LiveView (/live-client-side-loading).
Validates that the page mounts, shows heading and description, and that both
ClientSideLoading components hydrate and display content.
"""
use ExampleWeb.FeatureCase, async: false
@moduletag :e2e
defp wait_for_hydration(session, count, attempts \\ 30)
defp wait_for_hydration(_session, _count, 0), do: :ok
defp wait_for_hydration(session, count, attempts) do
els = session |> all(Query.css("[data-testid='client-side-loading-content']"))
if length(els) >= count do
:ok
else
:timer.sleep(100)
wait_for_hydration(session, count, attempts - 1)
end
end
test "page mounts and shows heading", %{session: session} do
session
|> visit("/live-client-side-loading")
|> find(Query.css("[data-testid='client-side-loading-heading']", text: "Client-side loading"))
end
test "renders description", %{session: session} do
session
|> visit("/live-client-side-loading")
|> find(Query.css("p", text: "Use the loading slot when SSR is disabled; the slot shows until the component hydrates on the client."))
end
test "both components hydrate and show content", %{session: session} do
session = visit(session, "/live-client-side-loading")
session |> find(Query.css("[data-testid='client-side-loading-heading']"))
wait_for_hydration(session, 2)
content_els = session |> all(Query.css("[data-testid='client-side-loading-content']"))
assert length(content_els) == 2,
"expected 2 hydrated ClientSideLoading components, got #{length(content_els)}"
for el <- content_els do
assert Wallaby.Element.text(el) == "This is the component!"
end
end
test "both sections show hydrated content after load", %{session: session} do
session = visit(session, "/live-client-side-loading")
wait_for_hydration(session, 2)
session |> assert_has(Query.css("[data-testid='client-side-loading-client-section']"))
session |> assert_has(Query.css("[data-testid='client-side-loading-server-section']"))
content_els = session |> all(Query.css("[data-testid='client-side-loading-content']"))
assert length(content_els) == 2
end
end

View file

@ -0,0 +1,66 @@
defmodule ExampleWeb.LiveJsonTest do
@moduledoc """
E2E test for the LiveJson LiveView (/live-json).
Validates that the page mounts with two sections (SSR and No SSR), shows key length
and byte size, and that clicking Remove element decreases the key count.
"""
use ExampleWeb.FeatureCase, async: false
@moduletag :e2e
# First key-count dd in the first section (SSR)
defp first_key_count_dd(session) do
session |> all(Query.css("[data-testid='live-json-key-count']")) |> List.first()
end
defp wait_for_key_count(session, expected, attempts \\ 50) do
dd = first_key_count_dd(session)
actual = dd && Wallaby.Element.text(dd)
cond do
actual == expected -> session
attempts == 0 -> raise "timeout waiting for key count #{inspect(expected)}, got #{inspect(actual)}"
true ->
:timer.sleep(100)
wait_for_key_count(session, expected, attempts - 1)
end
end
test "page mounts and shows heading and description", %{session: session} do
session
|> visit("/live-json")
|> assert_has(Query.css("h2", text: "Live JSON"))
|> assert_has(Query.css("p", text: "Large payloads are patched over the wire. Compare SSR vs no-SSR and watch the WebSocket traffic when removing elements."))
end
test "renders two sections with key length and Remove element button", %{session: session} do
session = visit(session, "/live-json")
# Two section cards (SSR and No SSR); at least one of each badge
ssr_badges = session |> all(Query.css("section.card span.badge", text: "SSR"))
no_ssr_badges = session |> all(Query.css("section.card span.badge", text: "No SSR"))
assert length(ssr_badges) >= 1
assert length(no_ssr_badges) >= 1
# Wait for Svelte to hydrate and show key count
session = wait_for_key_count(session, "100,000")
key_length_dts = session |> all(Query.css("dt", text: "Key length"))
assert length(key_length_dts) >= 1
remove_btns = session |> all(Query.css("[data-testid='live-json-remove-element']"))
assert length(remove_btns) >= 1
end
test "clicking Remove element decreases key count", %{session: session} do
session =
session
|> visit("/live-json")
|> wait_for_key_count("100,000")
# Click the first section's Remove element button
[remove_btn | _] = session |> all(Query.css("[data-testid='live-json-remove-element']"))
Wallaby.Element.click(remove_btn)
session = wait_for_key_count(session, "99,999")
dd = first_key_count_dd(session)
assert Wallaby.Element.text(dd) == "99,999"
end
end

View file

@ -0,0 +1,129 @@
defmodule ExampleWeb.LiveLightsTest do
@moduledoc """
E2E test for the LiveLights LiveView (Light Bulb Controller) with Svelte
LightStatusBar and LightControllers. Validates that the full stack renders,
up/down buttons update brightness, and the toggle turns the light off/on.
"""
use ExampleWeb.FeatureCase, async: false
@moduletag :e2e
defp wait_for_brightness(session, expected, attempts \\ 80) do
if attempts == 0 do
el = session |> find(Query.css("[data-testid='light-brightness-value']"))
actual = Wallaby.Element.text(el)
raise "timeout waiting for brightness (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
end
el = session |> find(Query.css("[data-testid='light-brightness-value']"))
case Wallaby.Element.text(el) do
^expected -> session
_ -> :timer.sleep(100); wait_for_brightness(session, expected, attempts - 1)
end
end
test "page mounts and shows heading", %{session: session} do
session
|> visit("/live-lights")
|> find(Query.css("h1", text: "Light Bulb Controller"))
end
test "renders LightStatusBar and LightControllers with initial brightness 10%", %{session: session} do
session = visit(session, "/live-lights")
session |> find(Query.css("[data-name='LightStatusBar']"))
session |> find(Query.css("[data-name='LightControllers']"))
value = session |> find(Query.css("[data-testid='light-brightness-value']"))
assert Wallaby.Element.text(value) == "10%"
end
test "clicking up increases brightness", %{session: session} do
session =
session
|> visit("/live-lights")
|> click(Query.css("[data-testid='light-up']"))
session = wait_for_brightness(session, "20%")
value = session |> find(Query.css("[data-testid='light-brightness-value']"))
assert Wallaby.Element.text(value) == "20%"
end
test "clicking down decreases brightness", %{session: session} do
session =
session
|> visit("/live-lights")
|> click(Query.css("[data-testid='light-down']"))
session = wait_for_brightness(session, "OFF")
value = session |> find(Query.css("[data-testid='light-brightness-value']"))
assert Wallaby.Element.text(value) == "OFF"
end
test "brightness does not go below 0", %{session: session} do
session =
session
|> visit("/live-lights")
|> click(Query.css("[data-testid='light-down']"))
session = wait_for_brightness(session, "OFF")
session = session |> click(Query.css("[data-testid='light-down']"))
value = session |> find(Query.css("[data-testid='light-brightness-value']"))
assert Wallaby.Element.text(value) == "OFF"
end
test "multiple up clicks increase brightness", %{session: session} do
session =
session
|> visit("/live-lights")
|> click(Query.css("[data-testid='light-up']"))
session = wait_for_brightness(session, "20%")
session = session |> click(Query.css("[data-testid='light-up']"))
session = wait_for_brightness(session, "30%")
session = session |> click(Query.css("[data-testid='light-up']"))
session = wait_for_brightness(session, "40%")
value = session |> find(Query.css("[data-testid='light-brightness-value']"))
assert Wallaby.Element.text(value) == "40%"
end
test "toggle off sets brightness to OFF", %{session: session} do
session =
session
|> visit("/live-lights")
|> click(Query.css("[data-testid='light-toggle']"))
session = wait_for_brightness(session, "OFF")
value = session |> find(Query.css("[data-testid='light-brightness-value']"))
assert Wallaby.Element.text(value) == "OFF"
end
test "toggle on after off restores previous brightness", %{session: session} do
session =
session
|> visit("/live-lights")
|> click(Query.css("[data-testid='light-toggle']"))
session = wait_for_brightness(session, "OFF")
session = session |> click(Query.css("[data-testid='light-toggle']"))
session = wait_for_brightness(session, "10%")
value = session |> find(Query.css("[data-testid='light-brightness-value']"))
assert Wallaby.Element.text(value) == "10%"
end
test "up then toggle off then on restores last brightness", %{session: session} do
session =
session
|> visit("/live-lights")
|> click(Query.css("[data-testid='light-up']"))
session = wait_for_brightness(session, "20%")
session = session |> click(Query.css("[data-testid='light-toggle']"))
session = wait_for_brightness(session, "OFF")
session = session |> click(Query.css("[data-testid='light-toggle']"))
session = wait_for_brightness(session, "20%")
value = session |> find(Query.css("[data-testid='light-brightness-value']"))
assert Wallaby.Element.text(value) == "20%"
end
end

View file

@ -0,0 +1,73 @@
defmodule ExampleWeb.LiveLogListTest do
@moduledoc """
E2E test for the LiveLogList LiveView (/live-log-list).
Validates that the page mounts, the Svelte LogList component shows the empty state,
adding an item via the UI updates the list, and the timer appends entries.
"""
use ExampleWeb.FeatureCase, async: false
@moduletag :e2e
defp log_list_items(session) do
session |> all(Query.css("[data-testid='log-list-items'] li"))
end
defp wait_for_log_item_with_text(session, text, attempts \\ 50) do
items = log_list_items(session)
found = Enum.any?(items, fn el -> Wallaby.Element.text(el) =~ text end)
cond do
found -> session
attempts == 0 -> raise "timeout waiting for log list item containing #{inspect(text)}"
true ->
:timer.sleep(100)
wait_for_log_item_with_text(session, text, attempts - 1)
end
end
defp wait_for_at_least_one_log_item(session, attempts \\ 35) do
items = log_list_items(session)
cond do
length(items) >= 1 -> session
attempts == 0 -> raise "timeout waiting for at least one timer-driven log entry"
true ->
:timer.sleep(200)
wait_for_at_least_one_log_item(session, attempts - 1)
end
end
test "page mounts and shows heading, input, and description", %{session: session} do
session
|> visit("/live-log-list")
|> assert_has(Query.css("h2", text: "Log stream"))
|> assert_has(Query.css("p", text: "Add items or let the timer append entries; limit how many are shown."))
|> assert_has(Query.css("[data-testid='log-list-new-entry']"))
# Empty state may be gone if timer already appended an entry
end
test "adding an item via the UI shows it in the list and clears the input", %{session: session} do
session =
session
|> visit("/live-log-list")
|> fill_in(Query.css("[data-testid='log-list-new-entry']"), with: "Hello")
|> click(Query.button("Add item"))
session = wait_for_log_item_with_text(session, "Hello")
items = log_list_items(session)
assert length(items) >= 1
assert Enum.any?(items, fn el -> Wallaby.Element.text(el) =~ "Hello" end)
input = session |> find(Query.css("[data-testid='log-list-new-entry']"))
assert Wallaby.Element.attr(input, "value") == ""
end
test "timer appends entries after mount", %{session: session} do
session =
session
|> visit("/live-log-list")
|> assert_has(Query.css("h2", text: "Log stream"))
session = wait_for_at_least_one_log_item(session)
items = log_list_items(session)
assert length(items) >= 1
end
end

View file

@ -0,0 +1,101 @@
defmodule ExampleWeb.LiveNotesOtpTest do
@moduledoc """
E2E test for the LiveNotesOtp LiveView (/live-notes-otp).
Validates that the page mounts with heading, description, form, empty state,
and that creating and deleting notes works.
"""
use ExampleWeb.FeatureCase, async: false
@moduletag :e2e
defp note_items(session) do
session |> all(Query.css("[data-testid='notes-otp-note']"))
end
defp wait_for_note_with_title(session, title, attempts \\ 50) do
notes = note_items(session)
found = Enum.any?(notes, fn el -> Wallaby.Element.text(el) =~ title end)
cond do
found ->
session
attempts == 0 ->
raise "timeout waiting for note with title #{inspect(title)}"
true ->
:timer.sleep(100)
wait_for_note_with_title(session, title, attempts - 1)
end
end
test "page mounts and shows heading, description, and form", %{session: session} do
session
|> visit("/live-notes-otp")
|> assert_has(Query.css("[data-testid='notes-otp-heading']", text: "Notes (OTP)"))
|> assert_has(
Query.css("p",
text:
"Ecto structs are encoded automatically. Changes sync in real time across all browsers via PubSub."
)
)
|> assert_has(Query.css("[data-testid='notes-otp-app']"))
|> assert_has(Query.css("[data-testid='notes-otp-form']"))
|> assert_has(Query.css("[data-testid='notes-otp-title']"))
|> assert_has(Query.css("[data-testid='notes-otp-submit']", text: "Add note"))
end
test "shows empty state or notes list", %{session: session} do
session = visit(session, "/live-notes-otp")
session |> assert_has(Query.css("[data-testid='notes-otp-list']"))
# With no notes we see empty state; with existing notes we see note items
empty_count = session |> all(Query.css("[data-testid='notes-otp-empty']")) |> length()
note_count = session |> all(Query.css("[data-testid='notes-otp-note']")) |> length()
assert empty_count > 0 or note_count > 0,
"expected either empty state or at least one note"
end
test "creating a note shows it in the list", %{session: session} do
session =
session
|> visit("/live-notes-otp")
|> fill_in(Query.css("[data-testid='notes-otp-title']"), with: "E2E Test Note")
|> fill_in(Query.css("[data-testid='notes-otp-content']"), with: "Optional content")
|> click(Query.css("[data-testid='notes-otp-submit']"))
session = wait_for_note_with_title(session, "E2E Test Note")
notes = note_items(session)
assert length(notes) >= 1
assert Enum.any?(notes, fn el -> Wallaby.Element.text(el) =~ "E2E Test Note" end)
assert Enum.any?(notes, fn el -> Wallaby.Element.text(el) =~ "Optional content" end)
# Title and content inputs cleared after submit
title_el = session |> find(Query.css("[data-testid='notes-otp-title']"))
assert Wallaby.Element.attr(title_el, "value") == ""
end
test "deleting a note removes it from the list", %{session: session} do
session =
session
|> visit("/live-notes-otp")
|> fill_in(Query.css("[data-testid='notes-otp-title']"), with: "Note To Delete")
|> click(Query.css("[data-testid='notes-otp-submit']"))
session = wait_for_note_with_title(session, "Note To Delete")
notes = note_items(session)
assert length(notes) >= 1
# Click delete on the first note (newest first; we just created this one)
delete_btns = session |> all(Query.css("[data-testid='notes-otp-delete']"))
Wallaby.Element.click(Enum.at(delete_btns, 0))
# Wait for empty state or for the note to disappear
:timer.sleep(400)
notes_after = session |> all(Query.css("[data-testid='notes-otp-note']"))
empty_visible = session |> all(Query.css("[data-testid='notes-otp-empty']")) |> length() > 0
assert empty_visible or length(notes_after) < length(notes),
"expected note to be deleted (empty state or fewer notes)"
end
end

View file

@ -0,0 +1,96 @@
defmodule ExampleWeb.LivePlusMinusHybridTest do
@moduledoc """
E2E test for the LivePlusMinusHybrid LiveView (/live-plus-minus-hybrid).
Validates that the page mounts, shows initial value 10, and plus/minus buttons
(phx-click set_number) update the value. Step amount is client state in Svelte;
filling the input then clicking plus sends number+amount to the server.
"""
use ExampleWeb.FeatureCase, async: false
@moduletag :e2e
defp wait_for_value(session, expected, attempts \\ 80) do
if attempts == 0 do
el = session |> find(Query.css("[data-testid='hybrid-plus-minus-value']"))
actual = Wallaby.Element.text(el)
raise "timeout waiting for value (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
end
el = session |> find(Query.css("[data-testid='hybrid-plus-minus-value']"))
case Wallaby.Element.text(el) do
^expected -> session
_ -> :timer.sleep(100); wait_for_value(session, expected, attempts - 1)
end
end
test "page mounts and shows heading", %{session: session} do
session
|> visit("/live-plus-minus-hybrid")
|> find(Query.css("h2", text: "Plus / Minus (Hybrid)"))
end
test "initial value is 10", %{session: session} do
session = visit(session, "/live-plus-minus-hybrid")
value = session |> find(Query.css("[data-testid='hybrid-plus-minus-value']"))
assert Wallaby.Element.text(value) == "10"
end
test "clicking plus increases value", %{session: session} do
session =
session
|> visit("/live-plus-minus-hybrid")
|> click(Query.css("[data-testid='hybrid-plus-minus-plus']"))
session = wait_for_value(session, "11")
value = session |> find(Query.css("[data-testid='hybrid-plus-minus-value']"))
assert Wallaby.Element.text(value) == "11"
end
test "clicking minus decreases value", %{session: session} do
session =
session
|> visit("/live-plus-minus-hybrid")
|> click(Query.css("[data-testid='hybrid-plus-minus-plus']"))
session = wait_for_value(session, "11")
session = session |> click(Query.css("[data-testid='hybrid-plus-minus-minus']"))
session = wait_for_value(session, "10")
value = session |> find(Query.css("[data-testid='hybrid-plus-minus-value']"))
assert Wallaby.Element.text(value) == "10"
end
test "step amount changes increment", %{session: session} do
session =
session
|> visit("/live-plus-minus-hybrid")
|> fill_in(Query.css("[data-testid='hybrid-plus-minus-step']"), with: "2")
|> click(Query.css("[data-testid='hybrid-plus-minus-plus']"))
# Svelte amount is 2; button sends value 10+2=12 to LiveView
session = wait_for_value(session, "12")
value = session |> find(Query.css("[data-testid='hybrid-plus-minus-value']"))
assert Wallaby.Element.text(value) == "12"
end
test "multiple plus clicks with stepper 2 keep stepper at 2 and increment by 2 each time", %{session: session} do
session =
session
|> visit("/live-plus-minus-hybrid")
|> fill_in(Query.css("[data-testid='hybrid-plus-minus-step']"), with: "2")
# Click plus 3 times: 10 -> 12 -> 14 -> 16
session = session |> click(Query.css("[data-testid='hybrid-plus-minus-plus']"))
session = wait_for_value(session, "12")
session = session |> click(Query.css("[data-testid='hybrid-plus-minus-plus']"))
session = wait_for_value(session, "14")
session = session |> click(Query.css("[data-testid='hybrid-plus-minus-plus']"))
session = wait_for_value(session, "16")
value = session |> find(Query.css("[data-testid='hybrid-plus-minus-value']"))
assert Wallaby.Element.text(value) == "16"
step_input = session |> find(Query.css("[data-testid='hybrid-plus-minus-step']"))
assert Wallaby.Element.attr(step_input, "value") == "2"
end
end

View file

@ -0,0 +1,75 @@
defmodule ExampleWeb.LivePlusMinusTest do
@moduledoc """
E2E test for the LivePlusMinus LiveView (/live-plus-minus).
Validates that the page mounts, shows initial value 10, and plus/minus buttons
update the value via server state. Step amount is tested by filling the input
and triggering keyup so the LiveView receives the new amount.
"""
use ExampleWeb.FeatureCase, async: false
@moduletag :e2e
defp wait_for_value(session, expected, attempts \\ 80) do
if attempts == 0 do
el = session |> find(Query.css("[data-testid='live-plus-minus-value']"))
actual = Wallaby.Element.text(el)
raise "timeout waiting for value (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
end
el = session |> find(Query.css("[data-testid='live-plus-minus-value']"))
case Wallaby.Element.text(el) do
^expected -> session
_ -> :timer.sleep(100); wait_for_value(session, expected, attempts - 1)
end
end
test "page mounts and shows heading", %{session: session} do
session
|> visit("/live-plus-minus")
|> find(Query.css("h2", text: "Plus / Minus (LiveView)"))
end
test "initial value is 10", %{session: session} do
session = visit(session, "/live-plus-minus")
value = session |> find(Query.css("[data-testid='live-plus-minus-value']"))
assert Wallaby.Element.text(value) == "10"
end
test "clicking plus increases value", %{session: session} do
session =
session
|> visit("/live-plus-minus")
|> click(Query.css("[data-testid='live-plus-minus-plus']"))
session = wait_for_value(session, "11")
value = session |> find(Query.css("[data-testid='live-plus-minus-value']"))
assert Wallaby.Element.text(value) == "11"
end
test "clicking minus decreases value", %{session: session} do
session =
session
|> visit("/live-plus-minus")
|> click(Query.css("[data-testid='live-plus-minus-plus']"))
session = wait_for_value(session, "11")
session = session |> click(Query.css("[data-testid='live-plus-minus-minus']"))
session = wait_for_value(session, "10")
value = session |> find(Query.css("[data-testid='live-plus-minus-value']"))
assert Wallaby.Element.text(value) == "10"
end
test "step amount changes increment", %{session: session} do
session =
session
|> visit("/live-plus-minus")
|> fill_in(Query.css("input[aria-label='Step amount']"), with: "2")
|> click(Query.css("[data-testid='live-plus-minus-plus']"))
# Clicking the button blurs the input, so phx-blur sends "2" and server uses amount 2
session = wait_for_value(session, "12")
value = session |> find(Query.css("[data-testid='live-plus-minus-value']"))
assert Wallaby.Element.text(value) == "12"
end
end

View file

@ -0,0 +1,104 @@
defmodule ExampleWeb.LiveSigilTest do
@moduledoc """
E2E test for the LiveSigil LiveView (~V sigil).
Validates that the page renders, server/client/combined values are correct,
and +server / +client interactions update state and persist correctly.
"""
use ExampleWeb.FeatureCase, async: false
@moduletag :e2e
defp wait_for_sigil_value(session, testid, expected, attempts \\ 50) do
if attempts == 0 do
el = session |> find(Query.css("[data-testid='#{testid}']"))
actual = Wallaby.Element.text(el)
raise "timeout waiting for #{testid} (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
end
el = session |> find(Query.css("[data-testid='#{testid}']"))
case Wallaby.Element.text(el) do
^expected -> session
_ -> :timer.sleep(100); wait_for_sigil_value(session, testid, expected, attempts - 1)
end
end
test "page mounts and shows heading", %{session: session} do
session
|> visit("/live-sigil")
|> find(Query.css("h1", text: "Svelte template (~V sigil)"))
end
test "renders initial server, client, and combined values", %{session: session} do
session = visit(session, "/live-sigil")
session = wait_for_sigil_value(session, "sigil-combined", "15")
server_el = session |> find(Query.css("[data-testid='sigil-server-number']"))
client_el = session |> find(Query.css("[data-testid='sigil-client-number']"))
combined_el = session |> find(Query.css("[data-testid='sigil-combined']"))
assert Wallaby.Element.text(server_el) == "10"
assert Wallaby.Element.text(client_el) == "5"
assert Wallaby.Element.text(combined_el) == "15"
end
test "clicking +server updates server number and combined", %{session: session} do
session =
session
|> visit("/live-sigil")
|> wait_for_sigil_value("sigil-combined", "15")
|> click(Query.css("[data-testid='sigil-increment-server']"))
session = wait_for_sigil_value(session, "sigil-combined", "16")
server_el = session |> find(Query.css("[data-testid='sigil-server-number']"))
client_el = session |> find(Query.css("[data-testid='sigil-client-number']"))
combined_el = session |> find(Query.css("[data-testid='sigil-combined']"))
assert Wallaby.Element.text(server_el) == "11"
assert Wallaby.Element.text(client_el) == "5"
assert Wallaby.Element.text(combined_el) == "16"
end
test "clicking +client updates client number and combined", %{session: session} do
session =
session
|> visit("/live-sigil")
|> wait_for_sigil_value("sigil-combined", "15")
|> click(Query.css("[data-testid='sigil-increment-client']"))
session = wait_for_sigil_value(session, "sigil-combined", "16")
server_el = session |> find(Query.css("[data-testid='sigil-server-number']"))
client_el = session |> find(Query.css("[data-testid='sigil-client-number']"))
combined_el = session |> find(Query.css("[data-testid='sigil-combined']"))
assert Wallaby.Element.text(server_el) == "10"
assert Wallaby.Element.text(client_el) == "6"
assert Wallaby.Element.text(combined_el) == "16"
end
test "client state persists after server increment", %{session: session} do
session =
session
|> visit("/live-sigil")
|> wait_for_sigil_value("sigil-combined", "15")
|> click(Query.css("[data-testid='sigil-increment-client']"))
session = wait_for_sigil_value(session, "sigil-client-number", "6")
session = session |> click(Query.css("[data-testid='sigil-increment-client']"))
session = wait_for_sigil_value(session, "sigil-client-number", "7")
# Now server increment: server 10 -> 11, client should stay 7, combined 18
session = session |> click(Query.css("[data-testid='sigil-increment-server']"))
session = wait_for_sigil_value(session, "sigil-combined", "18")
server_el = session |> find(Query.css("[data-testid='sigil-server-number']"))
client_el = session |> find(Query.css("[data-testid='sigil-client-number']"))
combined_el = session |> find(Query.css("[data-testid='sigil-combined']"))
assert Wallaby.Element.text(server_el) == "11"
assert Wallaby.Element.text(client_el) == "7"
assert Wallaby.Element.text(combined_el) == "18"
end
end

View file

@ -0,0 +1,260 @@
defmodule ExampleWeb.LiveSimpleCounterTest do
use ExampleWeb.FeatureCase, async: false
@moduledoc """
E2E test for the LiveSimpleCounter LiveView with Svelte SimpleCounter components.
Validates that the full stack (LiveView LiveSvelte hook Svelte) renders,
that server increment updates the native counter and Svelte server props, and
that server increments never reset or change either component's client state.
"""
@moduletag :e2e
defp native_increment_js, do: "document.querySelector(\"[data-testid='live-simple-counter-increment']\").click()"
defp click_native_increment(session) do
session |> Wallaby.Browser.execute_script(native_increment_js())
end
defp assert_client_values_unchanged(session, expected_first, expected_second) do
client_values = session |> all(Query.css("[data-testid='simple-counter-client-value']"))
assert length(client_values) == 2, "expected 2 SimpleCounter client value elements"
actual_first = Wallaby.Element.text(Enum.at(client_values, 0))
actual_second = Wallaby.Element.text(Enum.at(client_values, 1))
assert actual_first == expected_first,
"first component client state was affected by server increment (expected: #{expected_first}, got: #{actual_first})"
assert actual_second == expected_second,
"second component client state was affected by server increment (expected: #{expected_second}, got: #{actual_second})"
session
end
defp wait_for_client_counters(session, count, attempts \\ 30)
defp wait_for_client_counters(_session, _count, 0), do: :ok
defp wait_for_client_counters(session, count, attempts) do
els = session |> all(Query.css("[data-testid='simple-counter-client-value']"))
if length(els) >= count do
:ok
else
:timer.sleep(100)
wait_for_client_counters(session, count, attempts - 1)
end
end
# Wait for the native counter to show the given value (LiveView patch may be async).
defp wait_for_counter_value(session, expected) when is_binary(expected) do
wait_for_counter_value(session, expected, 100)
end
defp wait_for_counter_value(session, expected, 0) do
el = session |> find(Query.css("[data-testid='live-simple-counter-value']"))
actual = Wallaby.Element.text(el)
raise "timeout waiting for counter value (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
end
defp wait_for_counter_value(session, expected, attempts) do
el = session |> find(Query.css("[data-testid='live-simple-counter-value']"))
case Wallaby.Element.text(el) do
^expected -> session
_ -> :timer.sleep(100); wait_for_counter_value(session, expected, attempts - 1)
end
end
test "page mounts and shows heading", %{session: session} do
session
|> visit("/live-simple-counter")
|> find(Query.css("h1", text: "Simple Counter Demo"))
end
test "renders initial native counter and two SimpleCounter components", %{session: session} do
session = visit(session, "/live-simple-counter")
native_value = session |> find(Query.css("[data-testid='live-simple-counter-value']"))
assert Wallaby.Element.text(native_value) == "10"
svelte_components = session |> all(Query.css("[data-name='SimpleCounter']"))
assert length(svelte_components) == 2
end
test "clicking native +1 updates displayed number", %{session: session} do
session =
session
|> visit("/live-simple-counter")
|> click(Query.css("[data-testid='live-simple-counter-increment']"))
native_value = session |> find(Query.css("[data-testid='live-simple-counter-value']"))
assert Wallaby.Element.text(native_value) == "11"
end
test "increment updates server number in both Svelte components", %{session: session} do
session =
session
|> visit("/live-simple-counter")
|> click(Query.css("[data-testid='live-simple-counter-increment']"))
# Each SimpleCounter has a Server card with span.text-brand for the server number
svelte_server_numbers = session |> all(Query.css("[data-name='SimpleCounter'] span.text-brand"))
assert length(svelte_server_numbers) >= 2
for el <- Enum.take(svelte_server_numbers, 2), do: assert Wallaby.Element.text(el) == "11"
end
test "renders client counter at 1 in each SimpleCounter", %{session: session} do
session = visit(session, "/live-simple-counter")
# Wait for both Svelte components to mount and render client counter
wait_for_client_counters(session, 2)
client_values = session |> all(Query.css("[data-testid='simple-counter-client-value']"))
assert length(client_values) == 2
for el <- client_values, do: assert Wallaby.Element.text(el) == "1"
end
test "clicking client +1 updates only that component's client counter", %{session: session} do
session = visit(session, "/live-simple-counter")
# Wait for LiveView and Svelte to mount
session |> find(Query.css("[data-testid='live-simple-counter-value']"))
# Click the first SimpleCounter's client +1 button
[first_client_btn | _] = session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
Wallaby.Element.click(first_client_btn)
client_values = session |> all(Query.css("[data-testid='simple-counter-client-value']"))
assert length(client_values) == 2
# First component's client counter should be 2; second still 1
assert Wallaby.Element.text(Enum.at(client_values, 0)) == "2"
assert Wallaby.Element.text(Enum.at(client_values, 1)) == "1"
end
test "client state survives a single server increment", %{session: session} do
session = visit(session, "/live-simple-counter")
session |> find(Query.css("[data-testid='live-simple-counter-value']", text: "10"))
# Bump only the first component's client counter: 1 → 2
[first_client_btn | _] = session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
Wallaby.Element.click(first_client_btn)
:timer.sleep(200)
assert_client_values_unchanged(session, "2", "1")
# Server increment (10 → 11). Client state must not be affected.
session = session |> click_native_increment() |> wait_for_counter_value("11")
assert_client_values_unchanged(session, "2", "1")
end
test "client state survives multiple consecutive server increments", %{session: session} do
session = visit(session, "/live-simple-counter")
session |> find(Query.css("[data-testid='live-simple-counter-value']", text: "10"))
# First component client: 1 → 2 → 3
[first_client_btn | _] = session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
Wallaby.Element.click(first_client_btn)
:timer.sleep(100)
Wallaby.Element.click(first_client_btn)
:timer.sleep(200)
assert_client_values_unchanged(session, "3", "1")
# Server 10→11: client state must be unchanged
session = session |> click_native_increment() |> wait_for_counter_value("11")
assert_client_values_unchanged(session, "3", "1")
# Server 11→12: client state must be unchanged
session = session |> click_native_increment() |> wait_for_counter_value("12")
assert_client_values_unchanged(session, "3", "1")
# Server 12→13: client state must be unchanged
session = session |> click_native_increment() |> wait_for_counter_value("13")
assert_client_values_unchanged(session, "3", "1")
end
test "client state on second component only is unchanged by server increments", %{session: session} do
session = visit(session, "/live-simple-counter")
wait_for_client_counters(session, 2)
session |> find(Query.css("[data-testid='live-simple-counter-value']", text: "10"))
# Bump only the second component's client counter (index 1)
client_btns = session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
Wallaby.Element.click(Enum.at(client_btns, 1))
:timer.sleep(200)
assert_client_values_unchanged(session, "1", "2")
# Server increment must not affect either client counter
session = session |> click_native_increment() |> wait_for_counter_value("11")
assert_client_values_unchanged(session, "1", "2")
session = session |> click_native_increment() |> wait_for_counter_value("12")
assert_client_values_unchanged(session, "1", "2")
end
test "server increment never resets or changes client state on both components", %{session: session} do
session = visit(session, "/live-simple-counter")
wait_for_client_counters(session, 2)
session |> find(Query.css("[data-testid='live-simple-counter-value']", text: "10"))
# Different client state on each: first=4 (3 clicks from 1), second=5 (4 clicks from 1)
client_btns = session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
# First component: 1 → 2 → 3 → 4
for _ <- 1..3 do
Wallaby.Element.click(Enum.at(client_btns, 0))
:timer.sleep(100)
end
:timer.sleep(100)
# Second component: 1 → 2 → 3 → 4 → 5
for _ <- 1..4 do
Wallaby.Element.click(Enum.at(client_btns, 1))
:timer.sleep(80)
end
:timer.sleep(200)
assert_client_values_unchanged(session, "4", "5")
# Multiple server increments: client state must stay 4 and 5
session = session |> click_native_increment() |> wait_for_counter_value("11")
assert_client_values_unchanged(session, "4", "5")
session = session |> click_native_increment() |> wait_for_counter_value("12")
assert_client_values_unchanged(session, "4", "5")
session = session |> click_native_increment() |> wait_for_counter_value("13")
assert_client_values_unchanged(session, "4", "5")
end
test "server increments first, then client state: client state still unaffected by later server increments", %{session: session} do
session = visit(session, "/live-simple-counter")
wait_for_client_counters(session, 2)
session |> find(Query.css("[data-testid='live-simple-counter-value']", text: "10"))
# Server increments first (10 → 12)
session = session |> click_native_increment() |> wait_for_counter_value("11")
session = session |> click_native_increment() |> wait_for_counter_value("12")
# Then bump client state: first=2, second=4
client_btns = session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
Wallaby.Element.click(Enum.at(client_btns, 0))
:timer.sleep(100)
for _ <- 1..3, do: (Wallaby.Element.click(Enum.at(client_btns, 1)); :timer.sleep(80))
:timer.sleep(200)
assert_client_values_unchanged(session, "2", "4")
# More server increments must not touch client state
session = session |> click_native_increment() |> wait_for_counter_value("13")
assert_client_values_unchanged(session, "2", "4")
session = session |> click_native_increment() |> wait_for_counter_value("14")
assert_client_values_unchanged(session, "2", "4")
end
test "many server increments leave client state unchanged", %{session: session} do
session = visit(session, "/live-simple-counter")
wait_for_client_counters(session, 2)
session |> find(Query.css("[data-testid='live-simple-counter-value']", text: "10"))
# First component client = 2
[first_btn | _] = session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
Wallaby.Element.click(first_btn)
:timer.sleep(200)
assert_client_values_unchanged(session, "2", "1")
# 5 server increments (10 → 15): client state must stay 2 and 1
session = session |> click_native_increment() |> wait_for_counter_value("11")
assert_client_values_unchanged(session, "2", "1")
session = session |> click_native_increment() |> wait_for_counter_value("12")
assert_client_values_unchanged(session, "2", "1")
session = session |> click_native_increment() |> wait_for_counter_value("13")
assert_client_values_unchanged(session, "2", "1")
session = session |> click_native_increment() |> wait_for_counter_value("14")
assert_client_values_unchanged(session, "2", "1")
session = session |> click_native_increment() |> wait_for_counter_value("15")
assert_client_values_unchanged(session, "2", "1")
end
end

View file

@ -0,0 +1,59 @@
defmodule ExampleWeb.LiveSlotsDynamicTest do
@moduledoc """
E2E test for the LiveSlotsDynamic LiveView (/live-slots-dynamic).
Validates that the page mounts with default and named slot content,
shows initial number 1, and that clicking Increment updates the number in both slots.
"""
use ExampleWeb.FeatureCase, async: false
@moduletag :e2e
test "page mounts and shows heading, description, slots, and initial number", %{session: session} do
session
|> visit("/live-slots-dynamic")
|> assert_has(Query.css("h2", text: "Dynamic slots"))
|> assert_has(Query.css("p", text: "Default slot and named slot (:subtitle) both receive LiveView state; the button updates the number."))
# Slots card: badge, button, Opening/Closing, subtitle
session |> assert_has(Query.css("[data-testid='slots-card']"))
session |> assert_has(Query.css("[data-testid='slots-badge']", text: "Slots"))
session |> assert_has(Query.css("[data-testid='slots-dynamic-increment']", text: "Increment the number"))
session |> assert_has(Query.css("[data-testid='slots-opening']", text: "Opening"))
session |> assert_has(Query.css("[data-testid='slots-closing']", text: "Closing"))
session |> assert_has(Query.css("[data-testid='slots-subtitle']", text: "Svelte subtitle"))
# Initial number 1 appears in default slot and in subtitle
default_number = session |> find(Query.css("[data-testid='slots-dynamic-number']"))
subtitle_number = session |> find(Query.css("[data-testid='slots-dynamic-subtitle-number']"))
assert Wallaby.Element.text(default_number) == "1"
assert Wallaby.Element.text(subtitle_number) == "1"
end
test "clicking Increment the number updates the number in both slots", %{session: session} do
session =
session
|> visit("/live-slots-dynamic")
|> assert_has(Query.css("[data-testid='slots-dynamic-increment']"))
|> click(Query.css("[data-testid='slots-dynamic-increment']"))
# Wait for both number displays to show 2
session = wait_for_number(session, "2")
default_number = session |> find(Query.css("[data-testid='slots-dynamic-number']"))
subtitle_number = session |> find(Query.css("[data-testid='slots-dynamic-subtitle-number']"))
assert Wallaby.Element.text(default_number) == "2"
assert Wallaby.Element.text(subtitle_number) == "2"
end
defp wait_for_number(session, expected, attempts \\ 30) do
default_span = session |> all(Query.css("[data-testid='slots-dynamic-number']")) |> List.first()
actual = default_span && Wallaby.Element.text(default_span)
cond do
actual == expected -> session
attempts == 0 ->
raise "timeout waiting for number #{inspect(expected)}, got #{inspect(actual)}"
true ->
:timer.sleep(100)
wait_for_number(session, expected, attempts - 1)
end
end
end

View file

@ -0,0 +1,24 @@
defmodule ExampleWeb.LiveSlotsSimpleTest do
@moduledoc """
E2E test for the LiveSlotsSimple LiveView (/live-slots-simple).
Validates that the page mounts and shows the Slots Svelte component
with default slot content (Inside Slot) and card structure.
"""
use ExampleWeb.FeatureCase, async: false
@moduletag :e2e
test "page mounts and shows heading, description, and slot content", %{session: session} do
session
|> visit("/live-slots-simple")
|> assert_has(Query.css("h2", text: "Simple slots"))
|> assert_has(Query.css("p", text: "Phoenix slots are passed into the Svelte component as the default slot content."))
# Slots card: badge and slot content
session |> assert_has(Query.css("[data-testid='slots-card']"))
session |> assert_has(Query.css("[data-testid='slots-badge']", text: "Slots"))
session |> assert_has(Query.css("[data-testid='slots-default-content']", text: "Inside Slot"))
session |> assert_has(Query.css("[data-testid='slots-opening']", text: "Opening"))
session |> assert_has(Query.css("[data-testid='slots-closing']", text: "Closing"))
end
end

View file

@ -0,0 +1,80 @@
defmodule ExampleWeb.LiveStaticColorTest do
use ExampleWeb.FeatureCase, async: false
@moduledoc """
E2E test for the LiveStaticColor LiveView with Svelte components in a for loop.
Validates that the full stack (LiveView LiveSvelte hook Svelte) renders
and that adding elements does not cause existing components to disappear.
"""
@moduletag :e2e
test "page mounts and shows the expected Svelte components", %{session: session} do
session =
session
|> visit("/live-static-color")
|> assert_has(Query.css("h1", text: "Static Color Demo"))
|> assert_has(Query.css("p", text: "Passing dynamic props to a list of Svelte components from LiveView."))
# There are two Svelte grids (file-based + ~V sigil), each rendering the same color span.
session = wait_for_svelte_count(session, 6)
assert session |> all(Query.css("[data-testid='static-color-svelte-value']")) |> length() == 6
# We should see two cards per index (0..2).
for idx <- 0..2 do
assert session |> all(Query.css("h3", text: "Svelte component #{idx}")) |> length() == 2
end
end
test "adding an element increases Svelte component count and preserves all existing ones", %{session: session} do
session = visit(session, "/live-static-color")
session = wait_for_svelte_count(session, 6)
session =
session
|> click(Query.button("Add Element"))
# Two grids × 4 elements = 8 rendered spans
session = wait_for_svelte_count(session, 8)
assert session |> all(Query.css("[data-testid='static-color-svelte-value']")) |> length() == 8
# New index shows up twice (file-based + ~V sigil)
assert session |> all(Query.css("h3", text: "Svelte component 3")) |> length() == 2
end
test "clicking red button updates color in all Svelte components", %{session: session} do
session =
session
|> visit("/live-static-color")
|> click(Query.button("Change color to red"))
session = wait_for_svelte_count(session, 6)
svelte_values = session |> all(Query.css("[data-testid='static-color-svelte-value']"))
assert length(svelte_values) == 6
for value <- svelte_values, do: assert Wallaby.Element.text(value) == "RED"
end
test "adding elements after color change preserves color in all components", %{session: session} do
session =
session
|> visit("/live-static-color")
|> click(Query.button("Change color to red"))
|> click(Query.button("Add Element"))
# Wait for the added element to mount and render across both grids (LiveView patch + client hydration)
session = wait_for_svelte_count(session, 8)
svelte_values = session |> all(Query.css("[data-testid='static-color-svelte-value']"))
assert length(svelte_values) == 8
for value <- svelte_values, do: assert Wallaby.Element.text(value) == "RED"
end
defp wait_for_svelte_count(session, expected, attempts \\ 80) do
count = session |> all(Query.css("[data-testid='static-color-svelte-value']")) |> length()
cond do
count >= expected -> session
attempts == 0 -> raise "timeout waiting for #{expected} Svelte value elements, got #{count}"
true ->
:timer.sleep(100)
wait_for_svelte_count(session, expected, attempts - 1)
end
end
end

View file

@ -0,0 +1,30 @@
defmodule ExampleWeb.LiveStructTest do
use ExampleWeb.FeatureCase, async: false
@moduledoc """
E2E tests for the /live-struct LiveView (Struct Svelte component with struct props).
"""
@moduletag :e2e
test "live-struct page loads and shows Struct Demo", %{session: session} do
session = visit(session, "/live-struct")
session |> find(Query.css("h1", text: "Struct Demo"))
session |> find(Query.css("p", text: "Passing a struct to Svelte."))
end
test "struct data flows from LiveView to Svelte component and updates on server events", %{session: session} do
session = visit(session, "/live-struct")
# Verify initial struct from LiveView (%User{name: "Bob", age: 42})
session |> assert_has(Query.css("[data-testid='struct-json']", text: "Bob"))
session |> assert_has(Query.css("[data-testid='struct-json']", text: "42"))
# Trigger server-side change and verify it reaches the Svelte component
session |> click(Query.css("[data-testid='struct-randomize-age']"))
# Wallaby retries until the element no longer contains "42" (or times out).
# This can ONLY pass if data actually flows from LiveView → Svelte.
session |> refute_has(Query.css("[data-testid='struct-json']", text: "42"))
end
end

View file

@ -0,0 +1,39 @@
defmodule ExampleWeb.LodashTest do
use ExampleWeb.FeatureCase, async: false
@moduledoc """
E2E tests for the /lodash page (PageController + Lodash Svelte component).
"""
@moduletag :e2e
test "lodash page loads and shows Lodash Demo content", %{session: session} do
session = visit(session, "/lodash")
session |> find(Query.css("h1", text: "Lodash Demo"))
session |> find(Query.css("h2", text: "Sorted with lodash"))
end
test "lodash page shows unordered array from LiveView props", %{session: session} do
session = visit(session, "/lodash")
# Props are %{unordered: [10, 50, 25, 1, 3, 100, 40, 30]}
el = session |> find(Query.css("[data-testid='lodash-unordered']"))
assert Wallaby.Element.text(el) =~ "10"
assert Wallaby.Element.text(el) =~ "50"
assert Wallaby.Element.text(el) =~ "1"
assert Wallaby.Element.text(el) =~ "100"
end
test "lodash page shows sorted array from lodash sortBy", %{session: session} do
session = visit(session, "/lodash")
# Sorted: 1, 3, 10, 25, 30, 40, 50, 100
el = session |> find(Query.css("[data-testid='lodash-ordered']"))
text = Wallaby.Element.text(el)
assert text =~ "1"
assert text =~ "100"
assert text =~ "50"
# Order matters: 1 should appear before 10, 10 before 25, etc.
assert text =~ "1, 3, 10, 25, 30, 40, 50, 100"
end
end

View file

@ -0,0 +1,12 @@
defmodule ExampleWeb.PhoenixTest.HelloWorldTest do
use ExampleWeb.ConnCase
import PhoenixTest
@moduletag :phoenix_test
test "renders the HelloWorld Svelte component wrapper", %{conn: conn} do
conn
|> visit("/hello-world")
|> assert_has("[data-name='HelloWorld']")
end
end

View file

@ -0,0 +1,58 @@
defmodule ExampleWeb.PhoenixTest.LiveBreakingNewsTest do
@moduledoc """
PhoenixTest (in-process) for LiveBreakingNews (/live-breaking-news).
Validates that the page renders the inline ~V Svelte component with initial news,
and that simulating add_news_item and remove_news_item update props.
"""
use ExampleWeb.ConnCase, async: false
import PhoenixTest
import Phoenix.LiveViewTest
@moduletag :phoenix_test
setup do
ssr = Application.get_env(:live_svelte, :ssr, false)
Application.put_env(:live_svelte, :ssr, true)
on_exit(fn ->
Application.put_env(:live_svelte, :ssr, ssr)
end)
:ok
end
test "renders page heading and description", %{conn: conn} do
conn
|> visit("/live-breaking-news")
|> assert_has("h2", text: "Breaking News")
|> assert_has("p", text: "Add headlines and control the ticker speed; remove items from the list.")
end
test "renders inline Svelte mount and initial news in props", %{conn: conn} do
conn
|> visit("/live-breaking-news")
|> assert_has("[data-name='_build/Elixir.ExampleWeb.LiveBreakingNews']")
|> assert_has("[data-props*='\"body\":\"Giant Pink Elephant Sighted Downtown\"']")
|> assert_has("[data-props*='\"body\":\"Local Cat Becomes Mayor of Small Town\"']")
end
test "simulating add_news_item updates data-props", %{conn: conn} do
conn
|> visit("/live-breaking-news")
|> assert_has("[data-name='_build/Elixir.ExampleWeb.LiveBreakingNews']")
|> unwrap(fn view ->
render_click(view, "add_news_item", %{"body" => "Extra headline"})
end)
|> assert_has("[data-props*='\"body\":\"Extra headline\"']")
end
test "simulating remove_news_item updates data-props", %{conn: conn} do
conn
|> visit("/live-breaking-news")
|> assert_has("[data-props*='\"body\":\"Giant Pink Elephant Sighted Downtown\"']")
|> unwrap(fn view ->
render_click(view, "remove_news_item", %{"id" => 1})
end)
|> refute_has("[data-props*='\"body\":\"Giant Pink Elephant Sighted Downtown\"']")
end
end

View file

@ -0,0 +1,57 @@
defmodule ExampleWeb.PhoenixTest.LiveChatTest do
@moduledoc """
PhoenixTest (in-process) for LiveChat (/live-chat).
Validates that the page renders the join form, that setting a name shows the Chat
Svelte component, and that simulating send_message updates messages in props.
"""
use ExampleWeb.ConnCase, async: false
import PhoenixTest
import Phoenix.LiveViewTest
@moduletag :phoenix_test
setup do
ssr = Application.get_env(:live_svelte, :ssr, false)
Application.put_env(:live_svelte, :ssr, true)
on_exit(fn ->
Application.put_env(:live_svelte, :ssr, ssr)
end)
:ok
end
test "renders page heading and join form when not joined", %{conn: conn} do
conn
|> visit("/live-chat")
|> assert_has("h2", text: "Chat")
|> assert_has("p", text: "Enter your name to join; then send messages. Your name labels your bubbles.")
|> assert_has("[data-testid='chat-join-name']")
|> assert_has("[data-testid='chat-join-form'] button", text: "Join")
end
test "after joining, Chat component is shown with empty messages", %{conn: conn} do
conn
|> visit("/live-chat")
|> unwrap(fn view ->
view |> form("[data-testid='chat-join-form']", %{"name" => "Alice"}) |> render_submit()
end)
|> assert_has("[data-name='Chat']")
|> assert_has("[data-props*='\"name\":\"Alice\"']")
|> assert_has("[data-props*='\"messages\":[]']")
end
test "simulating send_message after join updates Chat data-props", %{conn: conn} do
conn
|> visit("/live-chat")
|> unwrap(fn view ->
view |> form("[data-testid='chat-join-form']", %{"name" => "Bob"}) |> render_submit()
end)
|> assert_has("[data-name='Chat']")
|> unwrap(fn view ->
render_click(view, "send_message", %{"body" => "Hello everyone"})
end)
|> assert_has("[data-props*='\"body\":\"Hello everyone\"']")
|> assert_has("[data-props*='\"name\":\"Bob\"']")
end
end

View file

@ -0,0 +1,40 @@
defmodule ExampleWeb.PhoenixTest.LiveClientSideLoadingTest do
@moduledoc """
PhoenixTest (in-process) for LiveClientSideLoading (/live-client-side-loading).
Validates that the page renders heading, description, two section cards with loading slots,
and two ClientSideLoading mount points. Uses default test config (ssr: false) so both
components show loading in initial HTML.
"""
use ExampleWeb.ConnCase, async: false
import PhoenixTest
@moduletag :phoenix_test
test "renders page heading and description", %{conn: conn} do
conn
|> visit("/live-client-side-loading")
|> assert_has("[data-testid='client-side-loading-heading']", text: "Client-side loading")
|> assert_has("p", text: "Use the loading slot when SSR is disabled; the slot shows until the component hydrates on the client.")
end
test "renders two ClientSideLoading mount points", %{conn: conn} do
conn
|> visit("/live-client-side-loading")
|> assert_has("[data-name='ClientSideLoading']", count: 2)
end
test "renders loading state in initial HTML", %{conn: conn} do
conn
|> visit("/live-client-side-loading")
|> assert_has("span", text: "Loading…")
end
test "renders both section cards with badges", %{conn: conn} do
conn
|> visit("/live-client-side-loading")
|> assert_has("[data-testid='client-side-loading-client-section']")
|> assert_has("[data-testid='client-side-loading-server-section']")
|> assert_has("span.badge", text: "Client side")
|> assert_has("span.badge", text: "Server side (avoid)")
end
end

View file

@ -0,0 +1,45 @@
defmodule ExampleWeb.PhoenixTest.LiveJsonTest do
@moduledoc """
PhoenixTest (in-process) for LiveJson (/live-json).
Validates that the page renders two LiveJson sections (SSR and No SSR)
and shows key length and Remove element button. Remove-element behavior
is covered in E2E (live_json_test.exs).
"""
use ExampleWeb.ConnCase, async: false
import PhoenixTest
@moduletag :phoenix_test
setup do
ssr = Application.get_env(:live_svelte, :ssr, false)
Application.put_env(:live_svelte, :ssr, true)
on_exit(fn ->
Application.put_env(:live_svelte, :ssr, ssr)
end)
:ok
end
test "renders page heading and description", %{conn: conn} do
conn
|> visit("/live-json")
|> assert_has("h2", text: "Live JSON")
|> assert_has("p", text: "Large payloads are patched over the wire. Compare SSR vs no-SSR and watch the WebSocket traffic when removing elements.")
end
test "renders two sections (SSR and No SSR) with LiveJson component", %{conn: conn} do
conn
|> visit("/live-json")
|> assert_has("span.badge", text: "SSR")
|> assert_has("span.badge", text: "No SSR")
|> assert_has("[data-name='LiveJson']", count: 2)
end
test "shows key length and Remove element button", %{conn: conn} do
conn
|> visit("/live-json")
|> assert_has("dt", text: "Key length")
|> assert_has("[data-testid='live-json-remove-element']")
end
end

View file

@ -0,0 +1,115 @@
defmodule ExampleWeb.PhoenixTest.LiveLightsTest do
@moduledoc """
PhoenixTest (in-process) for LiveLights.
Validates that the Light Bulb Controller page renders, Svelte components
receive props, and up/down/toggle events update brightness and isOn state.
Runs with SSR enabled so Svelte components (LightStatusBar, LightControllers)
are in the initial HTML and buttons/brightness value are available.
Toggle is simulated via render_click(view, "off" | "on", %{}) since the
checkbox uses Svelte pushEvent (no phx-click).
"""
use ExampleWeb.ConnCase, async: false
import PhoenixTest
import Phoenix.LiveViewTest
@moduletag :phoenix_test
# Simulate toggle by sending the same handle_event the Svelte component would push.
# Uses render_click(view, event, %{}) so we trigger the LiveView directly (like "js click"
# in E2E triggers the real button; here we bypass the checkbox and send the event by name).
defp trigger_toggle(session, event) when event in ["off", "on"] do
render_click(session.view, event, %{})
end
setup do
ssr = Application.get_env(:live_svelte, :ssr, false)
Application.put_env(:live_svelte, :ssr, true)
on_exit(fn ->
Application.put_env(:live_svelte, :ssr, ssr)
end)
:ok
end
test "renders page heading and description", %{conn: conn} do
conn
|> visit("/live-lights")
|> assert_has("h1", text: "Light Bulb Controller")
|> assert_has("p", text: "Same LiveView state drives the native counter and both Svelte components.")
end
test "renders LightStatusBar and LightControllers Svelte components with initial state", %{conn: conn} do
conn
|> visit("/live-lights")
|> assert_has("[data-name='LightStatusBar']", count: 1)
|> assert_has("[data-name='LightControllers']", count: 1)
|> assert_has("[data-props*='\"brightness\":10']", count: 1)
|> assert_has("[data-props*='\"isOn\":true']", count: 1)
end
test "initial brightness is 10%", %{conn: conn} do
conn
|> visit("/live-lights")
|> assert_has("[data-testid='light-brightness-value']", text: "10%")
end
test "clicking up increases brightness", %{conn: conn} do
conn
|> visit("/live-lights")
|> assert_has("[data-testid='light-brightness-value']", text: "10%")
|> click_button("[data-testid='light-up']", "")
|> assert_has("[data-props*='\"brightness\":20']", count: 1)
end
test "clicking down decreases brightness", %{conn: conn} do
conn
|> visit("/live-lights")
|> assert_has("[data-testid='light-brightness-value']", text: "10%")
|> click_button("[data-testid='light-down']", "")
|> assert_has("[data-props*='\"brightness\":0']", count: 1)
|> assert_has("[data-props*='\"isOn\":false']", count: 1)
end
test "brightness does not go below 0", %{conn: conn} do
conn
|> visit("/live-lights")
|> click_button("[data-testid='light-down']", "")
|> assert_has("[data-props*='\"brightness\":0']", count: 1)
|> click_button("[data-testid='light-down']", "")
|> assert_has("[data-props*='\"brightness\":0']", count: 1)
end
test "multiple up clicks increase brightness by 10 each", %{conn: conn} do
conn
|> visit("/live-lights")
|> assert_has("[data-testid='light-brightness-value']", text: "10%")
|> click_button("[data-testid='light-up']", "")
|> click_button("[data-testid='light-up']", "")
|> click_button("[data-testid='light-up']", "")
|> assert_has("[data-props*='\"brightness\":40']", count: 1)
end
# Toggle: we simulate the Svelte pushEvent("off"|"on") via render_click(view, "off"|"on", %{}),
# like "js click" in E2E triggering the server event. "Off" state is reachable with the down
# button; "on" (restore previous) is simulated with render_click. Full UI toggle in E2E (Wallaby).
test "toggle off (same state via down) and data-props show isOn false", %{conn: conn} do
conn
|> visit("/live-lights")
|> assert_has("[data-testid='light-brightness-value']", text: "10%")
|> click_button("[data-testid='light-down']", "")
|> assert_has("[data-props*='\"brightness\":0']", count: 1)
|> assert_has("[data-props*='\"isOn\":false']", count: 1)
end
@tag :skip
test "toggle on (via render_click) after off restores previous brightness", %{conn: conn} do
# When render_click(session.view, "on", %{}) updates the view in this flow, this test can run.
# Toggle on/off is fully covered in E2E (Wallaby).
session = conn |> visit("/live-lights")
session = session |> click_button("[data-testid='light-down']", "")
_ = trigger_toggle(session, "on")
session |> assert_has("[data-props*='\"brightness\":10']", count: 1)
end
end

View file

@ -0,0 +1,50 @@
defmodule ExampleWeb.PhoenixTest.LiveLogListTest do
@moduledoc """
PhoenixTest (in-process) for LiveLogList (/live-log-list).
Validates that the page renders the LogList Svelte component with empty items,
and that simulating the add_item pushEvent updates props and rendered HTML.
"""
use ExampleWeb.ConnCase, async: false
import PhoenixTest
import Phoenix.LiveViewTest
@moduletag :phoenix_test
setup do
ssr = Application.get_env(:live_svelte, :ssr, false)
Application.put_env(:live_svelte, :ssr, true)
on_exit(fn ->
Application.put_env(:live_svelte, :ssr, ssr)
end)
:ok
end
test "renders page heading and description", %{conn: conn} do
conn
|> visit("/live-log-list")
|> assert_has("h2", text: "Log stream")
|> assert_has("p", text: "Add items or let the timer append entries; limit how many are shown.")
end
test "renders LogList mount and initial empty items in props", %{conn: conn} do
conn
|> visit("/live-log-list")
|> assert_has("[data-name='LogList']")
|> assert_has("[data-props*='\"items\":[]']")
end
test "simulating add_item updates LiveView state and data-props", %{conn: conn} do
conn
|> visit("/live-log-list")
|> assert_has("[data-name='LogList']")
|> assert_has("[data-props*='\"items\":[]']")
|> assert_has("[data-testid='log-list-empty-state']")
|> unwrap(fn view ->
render_click(view, "add_item", %{"body" => "hello"})
end)
# After event, props are updated (Svelte inner HTML may not re-SSR on patch)
|> assert_has("[data-props*='\"body\":\"hello\"']")
end
end

View file

@ -0,0 +1,77 @@
defmodule ExampleWeb.PhoenixTest.LiveNotesOtpTest do
@moduledoc """
PhoenixTest (in-process) for LiveNotesOtp (/live-notes-otp).
Validates that the page renders heading, description, NotesApp with notes,
and that simulating create_note and delete_note update state.
"""
use ExampleWeb.ConnCase, async: false
import PhoenixTest
import Phoenix.LiveViewTest
@moduletag :phoenix_test
setup do
ssr = Application.get_env(:live_svelte, :ssr, false)
Application.put_env(:live_svelte, :ssr, true)
on_exit(fn ->
Application.put_env(:live_svelte, :ssr, ssr)
end)
:ok
end
test "renders page heading and description", %{conn: conn} do
conn
|> visit("/live-notes-otp")
|> assert_has("[data-testid='notes-otp-heading']", text: "Notes (OTP)")
|> assert_has("p", text: "Ecto structs are encoded automatically. Changes sync in real time across all browsers via PubSub.")
end
test "renders NotesApp with form and empty state or notes in props", %{conn: conn} do
conn
|> visit("/live-notes-otp")
|> assert_has("[data-name='NotesApp']")
|> assert_has("[data-testid='notes-otp-form']")
|> assert_has("[data-testid='notes-otp-title']")
|> assert_has("[data-testid='notes-otp-submit']", text: "Add note")
end
test "simulating create_note adds note to props", %{conn: conn} do
conn
|> visit("/live-notes-otp")
|> assert_has("[data-name='NotesApp']")
|> unwrap(fn view ->
render_click(view, "create_note", %{
"title" => "Phoenix Test Note",
"content" => "Optional content",
"color" => "#fef3c7"
})
end)
|> assert_has("[data-props*='\"title\":\"Phoenix Test Note\"']")
|> assert_has("[data-props*='\"content\":\"Optional content\"']")
end
test "simulating delete_note removes note from props", %{conn: conn} do
conn =
conn
|> visit("/live-notes-otp")
|> unwrap(fn view ->
render_click(view, "create_note", %{
"title" => "To Be Deleted",
"content" => "",
"color" => "#fef3c7"
})
end)
|> assert_has("[data-props*='\"title\":\"To Be Deleted\"']")
note = Enum.find(Example.Notes.list_notes(), &(&1.title == "To Be Deleted"))
refute is_nil(note), "expected note To Be Deleted to exist after create_note"
conn
|> unwrap(fn view ->
render_click(view, "delete_note", %{"id" => note.id})
end)
|> refute_has("[data-props*='\"title\":\"To Be Deleted\"']")
end
end

View file

@ -0,0 +1,46 @@
defmodule ExampleWeb.PhoenixTest.LivePlusMinusHybridTest do
@moduledoc """
PhoenixTest (in-process) for LivePlusMinusHybrid LiveView (/live-plus-minus-hybrid).
Runs with SSR enabled so the CounterHybrid Svelte component is in the initial HTML.
Validates that the page renders, initial value is 10, and plus/minus buttons are present.
Click behavior (set_number) is covered by Wallaby E2E (live_plus_minus_hybrid_test.exs):
click_button does not trigger phx-click on buttons inside the Svelte-rendered DOM.
"""
use ExampleWeb.ConnCase, async: false
import PhoenixTest
@moduletag :phoenix_test
setup do
ssr = Application.get_env(:live_svelte, :ssr, false)
Application.put_env(:live_svelte, :ssr, true)
on_exit(fn ->
Application.put_env(:live_svelte, :ssr, ssr)
end)
:ok
end
test "renders page heading and description", %{conn: conn} do
conn
|> visit("/live-plus-minus-hybrid")
|> assert_has("h2", text: "Plus / Minus (Hybrid)")
|> assert_has("p", text: "LiveView-driven value with phx-click; step amount is client state.")
end
test "renders CounterHybrid Svelte component with initial props", %{conn: conn} do
conn
|> visit("/live-plus-minus-hybrid")
|> assert_has("[data-name='CounterHybrid']")
|> assert_has("[data-props*='\"number\":10']")
end
test "renders initial value and plus/minus buttons", %{conn: conn} do
conn
|> visit("/live-plus-minus-hybrid")
|> assert_has("[data-testid='hybrid-plus-minus-value']", text: "10")
|> assert_has("[data-testid='hybrid-plus-minus-minus']")
|> assert_has("[data-testid='hybrid-plus-minus-plus']")
end
end

View file

@ -0,0 +1,55 @@
defmodule ExampleWeb.PhoenixTest.LivePlusMinusTest do
@moduledoc """
PhoenixTest (in-process) for LivePlusMinus LiveView (/live-plus-minus).
Validates that the page renders, initial value is 10, and click_button
(phx-click) updates the displayed value. Step amount can be tested by
filling the input and clicking; LiveView receives phx-keyup for amount.
"""
use ExampleWeb.ConnCase, async: false
import PhoenixTest
@moduletag :phoenix_test
test "renders page heading and description", %{conn: conn} do
conn
|> visit("/live-plus-minus")
|> assert_has("h2", text: "Plus / Minus (LiveView)")
|> assert_has("p", text: "Native LiveView: value and step amount are both server state.")
end
test "renders initial value and plus/minus buttons", %{conn: conn} do
conn
|> visit("/live-plus-minus")
|> assert_has("[data-testid='live-plus-minus-value']", text: "10")
|> assert_has("[data-testid='live-plus-minus-minus']")
|> assert_has("[data-testid='live-plus-minus-plus']")
end
test "clicking plus increases value", %{conn: conn} do
conn
|> visit("/live-plus-minus")
|> assert_has("[data-testid='live-plus-minus-value']", text: "10")
|> click_button("[data-testid='live-plus-minus-plus']", "")
|> assert_has("[data-testid='live-plus-minus-value']", text: "11")
end
test "clicking minus decreases value", %{conn: conn} do
conn
|> visit("/live-plus-minus")
|> assert_has("[data-testid='live-plus-minus-value']", text: "10")
|> click_button("[data-testid='live-plus-minus-plus']", "")
|> assert_has("[data-testid='live-plus-minus-value']", text: "11")
|> click_button("[data-testid='live-plus-minus-minus']", "")
|> assert_has("[data-testid='live-plus-minus-value']", text: "10")
end
test "multiple plus clicks increase value", %{conn: conn} do
conn
|> visit("/live-plus-minus")
|> assert_has("[data-testid='live-plus-minus-value']", text: "10")
|> click_button("[data-testid='live-plus-minus-plus']", "")
|> click_button("[data-testid='live-plus-minus-plus']", "")
|> click_button("[data-testid='live-plus-minus-plus']", "")
|> assert_has("[data-testid='live-plus-minus-value']", text: "13")
end
end

View file

@ -0,0 +1,68 @@
defmodule ExampleWeb.PhoenixTest.LiveSigilTest do
@moduledoc """
PhoenixTest (in-process) for LiveSigil (~V sigil).
Runs with SSR enabled so the inline Svelte content is in the initial HTML.
Validates server number, client number, combined value. +server is tested via
render_click (phx-click); +client is JS-only and covered in E2E (Wallaby).
"""
use ExampleWeb.ConnCase, async: false
import PhoenixTest
import Phoenix.LiveViewTest
@moduletag :phoenix_test
setup do
ssr = Application.get_env(:live_svelte, :ssr, false)
Application.put_env(:live_svelte, :ssr, true)
on_exit(fn ->
Application.put_env(:live_svelte, :ssr, ssr)
end)
:ok
end
test "renders page heading and description", %{conn: conn} do
conn
|> visit("/live-sigil")
|> assert_has("h1", text: "Svelte template (~V sigil)")
|> assert_has("p", text: "Inline Svelte in LiveView: server state and client state in one template.")
end
test "renders initial server, client, and combined values", %{conn: conn} do
conn
|> visit("/live-sigil")
|> assert_has("[data-testid='sigil-server-number']", text: "10")
|> assert_has("[data-testid='sigil-client-number']", text: "5")
|> assert_has("[data-testid='sigil-combined']", text: "15")
end
test "clicking +server updates server number, client unchanged, and combined sum", %{conn: conn} do
conn
|> visit("/live-sigil")
# Initial: total sum 15, client 5, server 10
|> assert_has("[data-testid='sigil-server-number']", text: "10")
|> assert_has("[data-testid='sigil-client-number']", text: "5")
|> assert_has("[data-testid='sigil-combined']", text: "15")
|> unwrap(fn view ->
render_click(view, "increment", %{})
end)
# After click: server 11 (formula 11 + 5 = 16; client unchanged). ~V inner DOM not re-rendered server-side.
|> assert_has("[data-props*='\"number\":11']")
end
test "multiple +server updates: server 12, client still 5, combined 17", %{conn: conn} do
conn
|> visit("/live-sigil")
# Initial: total sum 15, client 5
|> assert_has("[data-testid='sigil-combined']", text: "15")
|> assert_has("[data-testid='sigil-client-number']", text: "5")
|> assert_has("[data-testid='sigil-server-number']", text: "10")
|> unwrap(fn view ->
render_click(view, "increment", %{})
render_click(view, "increment", %{})
end)
# Server 12 (formula 12 + 5 = 17)
|> assert_has("[data-props*='\"number\":12']")
end
end

View file

@ -0,0 +1,133 @@
defmodule ExampleWeb.PhoenixTest.LiveSimpleCounterTest do
@moduledoc """
PhoenixTest (in-process) for LiveSimpleCounter.
Runs with SSR enabled and a distinct initial client value (42) so we can assert
the client counter is bound to state, not hardcoded. If the component hardcodes
"1" instead of using {other}, these tests fail.
"""
use ExampleWeb.ConnCase, async: false
import PhoenixTest
@moduletag :phoenix_test
# Distinct value so we can assert client counter is reactive (fails if hardcoded "1").
@initial_client_value 42
setup do
ssr = Application.get_env(:live_svelte, :ssr, false)
initial = Application.get_env(:example, :simple_counter_initial_client_value, 1)
Application.put_env(:live_svelte, :ssr, true)
Application.put_env(:example, :simple_counter_initial_client_value, @initial_client_value)
on_exit(fn ->
Application.put_env(:live_svelte, :ssr, ssr)
Application.put_env(:example, :simple_counter_initial_client_value, initial)
end)
:ok
end
defp assert_simple_counter_mount_points_unchanged(conn, expected_count \\ 2) do
conn
|> assert_has("[data-name='SimpleCounter']", count: expected_count)
end
defp assert_client_counter_rendered_from_state(conn) do
conn
|> assert_has("[data-testid='simple-counter-client-value']", text: "#{@initial_client_value}", count: 2)
end
test "renders page heading and description", %{conn: conn} do
conn
|> visit("/live-simple-counter")
|> assert_has("h1", text: "Simple Counter Demo")
|> assert_has("p", text: "Same LiveView state drives the native counter and both Svelte components.")
end
test "renders initial counter and two SimpleCounter Svelte components", %{conn: conn} do
conn
|> visit("/live-simple-counter")
|> assert_has("[data-testid='live-simple-counter-value']", text: "10")
|> assert_has("[data-name='SimpleCounter']", count: 2)
|> assert_has("[data-props*='\"number\":10']", count: 2)
|> assert_client_counter_rendered_from_state()
end
test "clicking +1 updates LiveView and Svelte component props", %{conn: conn} do
conn
|> visit("/live-simple-counter")
|> assert_has("[data-testid='live-simple-counter-value']", text: "10")
|> assert_client_counter_rendered_from_state()
|> click_button("[data-testid='live-simple-counter-increment']", "+1")
|> assert_has("[data-testid='live-simple-counter-value']", text: "11")
|> assert_has("[data-props*='\"number\":11']", count: 2)
|> assert_client_counter_rendered_from_state()
end
test "renders SimpleCounter components with server and client state mount points", %{conn: conn} do
conn
|> visit("/live-simple-counter")
|> assert_has("[data-name='SimpleCounter']", count: 2)
|> assert_has("[data-props*='\"number\":10']", count: 2)
|> assert_client_counter_rendered_from_state()
end
test "server increment does not remove or re-mount SimpleCounter components", %{conn: conn} do
conn
|> visit("/live-simple-counter")
|> assert_has("[data-name='SimpleCounter']", count: 2)
|> assert_client_counter_rendered_from_state()
|> assert_has("[data-testid='live-simple-counter-value']", text: "10")
|> click_button("[data-testid='live-simple-counter-increment']", "+1")
|> assert_has("[data-testid='live-simple-counter-value']", text: "11")
|> assert_has("[data-props*='\"number\":11']", count: 2)
|> assert_simple_counter_mount_points_unchanged()
|> assert_client_counter_rendered_from_state()
end
test "multiple consecutive server increments update only server state and props", %{conn: conn} do
conn
|> visit("/live-simple-counter")
|> assert_has("[data-name='SimpleCounter']", count: 2)
|> assert_client_counter_rendered_from_state()
|> assert_has("[data-testid='live-simple-counter-value']", text: "10")
|> click_button("[data-testid='live-simple-counter-increment']", "+1")
|> assert_has("[data-testid='live-simple-counter-value']", text: "11")
|> assert_has("[data-props*='\"number\":11']", count: 2)
|> assert_simple_counter_mount_points_unchanged()
|> assert_client_counter_rendered_from_state()
|> click_button("[data-testid='live-simple-counter-increment']", "+1")
|> assert_has("[data-testid='live-simple-counter-value']", text: "12")
|> assert_has("[data-props*='\"number\":12']", count: 2)
|> assert_simple_counter_mount_points_unchanged()
|> assert_client_counter_rendered_from_state()
|> click_button("[data-testid='live-simple-counter-increment']", "+1")
|> assert_has("[data-testid='live-simple-counter-value']", text: "13")
|> assert_has("[data-props*='\"number\":13']", count: 2)
|> assert_simple_counter_mount_points_unchanged()
|> assert_client_counter_rendered_from_state()
end
test "many server increments leave SimpleCounter mount points intact", %{conn: conn} do
conn =
conn
|> visit("/live-simple-counter")
|> assert_has("[data-name='SimpleCounter']", count: 2)
|> assert_client_counter_rendered_from_state()
# 5 server increments (10 → 15); mount points and client value must stay after each
conn =
Enum.reduce(1..5, conn, fn _i, acc ->
acc
|> click_button("[data-testid='live-simple-counter-increment']", "+1")
|> assert_simple_counter_mount_points_unchanged()
|> assert_client_counter_rendered_from_state()
end)
conn
|> assert_has("[data-testid='live-simple-counter-value']", text: "15")
|> assert_has("[data-props*='\"number\":15']", count: 2)
|> assert_simple_counter_mount_points_unchanged()
|> assert_client_counter_rendered_from_state()
end
end

View file

@ -0,0 +1,49 @@
defmodule ExampleWeb.PhoenixTest.LiveSlotsDynamicTest do
@moduledoc """
PhoenixTest (in-process) for LiveSlotsDynamic (/live-slots-dynamic).
Validates that the page renders the Slots Svelte component with default and
named (:subtitle) slots bound to LiveView state, and that the increment event updates the number.
"""
use ExampleWeb.ConnCase, async: false
import PhoenixTest
@moduletag :phoenix_test
setup do
ssr = Application.get_env(:live_svelte, :ssr, false)
Application.put_env(:live_svelte, :ssr, true)
on_exit(fn ->
Application.put_env(:live_svelte, :ssr, ssr)
end)
:ok
end
test "renders page heading and description", %{conn: conn} do
conn
|> visit("/live-slots-dynamic")
|> assert_has("h2", text: "Dynamic slots")
|> assert_has("p", text: "Default slot and named slot (:subtitle) both receive LiveView state; the button updates the number.")
end
test "renders Slots with default and subtitle slots showing initial number", %{conn: conn} do
conn
|> visit("/live-slots-dynamic")
|> assert_has("[data-name='Slots']")
|> assert_has("[data-testid='slots-badge']", text: "Slots")
|> assert_has("[data-testid='slots-dynamic-increment']", text: "Increment the number")
|> assert_has("[data-testid='slots-opening']", text: "Opening")
|> assert_has("[data-testid='slots-closing']", text: "Closing")
|> assert_has("[data-testid='slots-subtitle']", text: "Svelte subtitle")
|> assert_has("[data-testid='slots-dynamic-number']", text: "1")
|> assert_has("[data-testid='slots-dynamic-subtitle-number']", text: "1")
end
# Increment click and number update are covered in E2E; slot content may not re-render server-side after event.
test "increment button is present", %{conn: conn} do
conn
|> visit("/live-slots-dynamic")
|> assert_has("[data-testid='slots-dynamic-increment']", text: "Increment the number")
end
end

View file

@ -0,0 +1,38 @@
defmodule ExampleWeb.PhoenixTest.LiveSlotsSimpleTest do
@moduledoc """
PhoenixTest (in-process) for LiveSlotsSimple (/live-slots-simple).
Validates that the page renders the Slots Svelte component with default slot content.
"""
use ExampleWeb.ConnCase, async: false
import PhoenixTest
@moduletag :phoenix_test
setup do
ssr = Application.get_env(:live_svelte, :ssr, false)
Application.put_env(:live_svelte, :ssr, true)
on_exit(fn ->
Application.put_env(:live_svelte, :ssr, ssr)
end)
:ok
end
test "renders page heading and description", %{conn: conn} do
conn
|> visit("/live-slots-simple")
|> assert_has("h2", text: "Simple slots")
|> assert_has("p", text: "Phoenix slots are passed into the Svelte component as the default slot content.")
end
test "renders Slots component with default slot content", %{conn: conn} do
conn
|> visit("/live-slots-simple")
|> assert_has("[data-name='Slots']")
|> assert_has("[data-testid='slots-badge']", text: "Slots")
|> assert_has("[data-testid='slots-default-content']", text: "Inside Slot")
|> assert_has("[data-testid='slots-opening']", text: "Opening")
|> assert_has("[data-testid='slots-closing']", text: "Closing")
end
end

View file

@ -0,0 +1,67 @@
defmodule ExampleWeb.PhoenixTest.LiveStaticColorTest do
use ExampleWeb.ConnCase
import PhoenixTest
@moduletag :phoenix_test
test "renders page heading and description", %{conn: conn} do
conn
|> visit("/live-static-color")
|> assert_has("h1", text: "Static Color Demo")
|> assert_has("p", text: "Passing dynamic props to a list of Svelte components from LiveView.")
end
test "renders both Svelte mountpoints (file-based + ~V sigil) initially with white color", %{conn: conn} do
conn
|> visit("/live-static-color")
|> assert_has("[data-name='Static']", count: 3)
|> assert_has("[data-name='_build/Elixir.ExampleWeb.LiveStaticColor']", count: 3)
|> assert_has("[data-props*='\"color\":\"white\"']", count: 6)
end
test "each Svelte component receives its index in props (twice: file-based + ~V sigil)", %{conn: conn} do
conn
|> visit("/live-static-color")
|> assert_has("[data-props*='\"index\":0']", count: 2)
|> assert_has("[data-props*='\"index\":1']", count: 2)
|> assert_has("[data-props*='\"index\":2']", count: 2)
end
test "adding an element increases both mountpoint counts by one", %{conn: conn} do
conn
|> visit("/live-static-color")
|> assert_has("[data-name='Static']", count: 3)
|> assert_has("[data-name='_build/Elixir.ExampleWeb.LiveStaticColor']", count: 3)
|> click_button("Add Element")
|> assert_has("[data-name='Static']", count: 4)
|> assert_has("[data-name='_build/Elixir.ExampleWeb.LiveStaticColor']", count: 4)
end
test "adding an element preserves existing indices and adds index 3 twice", %{conn: conn} do
conn
|> visit("/live-static-color")
|> click_button("Add Element")
|> assert_has("[data-props*='\"index\":0']", count: 2)
|> assert_has("[data-props*='\"index\":1']", count: 2)
|> assert_has("[data-props*='\"index\":2']", count: 2)
|> assert_has("[data-props*='\"index\":3']", count: 2)
end
test "clicking red updates all Svelte components to red", %{conn: conn} do
conn
|> visit("/live-static-color")
|> click_button("Change color to red")
|> assert_has("[data-props*='\"color\":\"red\"']", count: 6)
|> refute_has("[data-props*='\"color\":\"white\"']")
end
test "adding elements after color change preserves color for all components", %{conn: conn} do
conn
|> visit("/live-static-color")
|> click_button("Change color to red")
|> click_button("Add Element")
|> assert_has("[data-name='Static']", count: 4)
|> assert_has("[data-name='_build/Elixir.ExampleWeb.LiveStaticColor']", count: 4)
|> assert_has("[data-props*='\"color\":\"red\"']", count: 8)
end
end

View file

@ -0,0 +1,36 @@
defmodule ExampleWeb.PhoenixTest.LiveStructTest do
use ExampleWeb.ConnCase
import PhoenixTest
@moduletag :phoenix_test
test "renders page heading and description", %{conn: conn} do
conn
|> visit("/live-struct")
|> assert_has("h1", text: "Struct Demo")
|> assert_has("p", text: "Passing a struct to Svelte.")
end
test "renders Struct component with correct shape and initial data", %{conn: conn} do
conn
|> visit("/live-struct")
|> assert_has("[data-name='Struct']")
# Verify the props contain a "struct" key with "name" and "age" fields
|> assert_has("[data-props*='\"struct\"']")
|> assert_has("[data-props*='\"name\":\"Bob\"']")
|> assert_has("[data-props*='\"age\":42']")
end
test "clicking randomize changes the age while preserving the name", %{conn: conn} do
conn
|> visit("/live-struct")
|> assert_has("[data-props*='\"age\":42']")
|> unwrap(fn view ->
Phoenix.LiveViewTest.render_click(view, "randomize")
end)
# Name should still be Bob
|> assert_has("[data-props*='\"name\":\"Bob\"']")
# Age should have changed from 42 (1% chance of flake if random lands on 42)
|> refute_has("[data-props*='\"age\":42']")
end
end

View file

@ -0,0 +1,13 @@
defmodule ExampleWeb.PhoenixTest.LodashTest do
use ExampleWeb.ConnCase
import PhoenixTest
@moduletag :phoenix_test
test "renders the Lodash Svelte component with the unordered array in props", %{conn: conn} do
conn
|> visit("/lodash")
|> assert_has("[data-name='Lodash']")
|> assert_has("[data-props*='[10,50,25,1,3,100,40,30]']")
end
end

View file

@ -0,0 +1,41 @@
defmodule ExampleWeb.PhoenixTest.PlusMinusSvelteTest do
@moduledoc """
PhoenixTest (in-process) for /plus-minus-svelte.
Asserts the PlusMinus Svelte component wrapper, initial props, and that the
value/buttons are present in the HTML by data-testid when SSR is on.
Clicking plus/minus is not tested here: on static pages PhoenixTest's
click_button requires the button to be inside a form (or a LiveView); our
Svelte buttons are plain onclick with no form, so click_button raises.
For click behavior and value updates, see Wallaby E2E (plus_minus_svelte_test.exs).
"""
use ExampleWeb.ConnCase, async: false
import PhoenixTest
@moduletag :phoenix_test
setup do
ssr = Application.get_env(:live_svelte, :ssr, false)
Application.put_env(:live_svelte, :ssr, true)
on_exit(fn ->
Application.put_env(:live_svelte, :ssr, ssr)
end)
:ok
end
test "renders the PlusMinus Svelte component wrapper with initial props", %{conn: conn} do
conn
|> visit("/plus-minus-svelte")
|> assert_has("[data-name='PlusMinus']")
|> assert_has("[data-props*='\"number\":10']")
end
test "renders initial value and plus/minus buttons in HTML by data-testid with SSR", %{conn: conn} do
conn
|> visit("/plus-minus-svelte")
|> assert_has("[data-testid='plus-minus-value']", text: "10")
|> assert_has("[data-testid='plus-minus-minus']")
|> assert_has("[data-testid='plus-minus-plus']")
end
end

View file

@ -0,0 +1,72 @@
defmodule ExampleWeb.PlusMinusSvelteTest do
@moduledoc """
E2E test for the /plus-minus-svelte page (PageController + PlusMinus Svelte component).
Validates that the page mounts, shows initial value 10, and plus/minus buttons update the value.
"""
use ExampleWeb.FeatureCase, async: false
@moduletag :e2e
defp wait_for_value(session, expected, attempts \\ 80) do
if attempts == 0 do
el = session |> find(Query.css("[data-testid='plus-minus-value']"))
actual = Wallaby.Element.text(el)
raise "timeout waiting for value (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
end
el = session |> find(Query.css("[data-testid='plus-minus-value']"))
case Wallaby.Element.text(el) do
^expected -> session
_ -> :timer.sleep(100); wait_for_value(session, expected, attempts - 1)
end
end
test "page mounts and shows heading", %{session: session} do
session
|> visit("/plus-minus-svelte")
|> find(Query.css("h2", text: "Plus / Minus"))
end
test "initial value is 10", %{session: session} do
session = visit(session, "/plus-minus-svelte")
value = session |> find(Query.css("[data-testid='plus-minus-value']"))
assert Wallaby.Element.text(value) == "10"
end
test "clicking plus increases value", %{session: session} do
session =
session
|> visit("/plus-minus-svelte")
|> click(Query.css("[data-testid='plus-minus-plus']"))
session = wait_for_value(session, "11")
value = session |> find(Query.css("[data-testid='plus-minus-value']"))
assert Wallaby.Element.text(value) == "11"
end
test "clicking minus decreases value", %{session: session} do
session =
session
|> visit("/plus-minus-svelte")
|> click(Query.css("[data-testid='plus-minus-plus']"))
session = wait_for_value(session, "11")
session = session |> click(Query.css("[data-testid='plus-minus-minus']"))
session = wait_for_value(session, "10")
value = session |> find(Query.css("[data-testid='plus-minus-value']"))
assert Wallaby.Element.text(value) == "10"
end
test "step amount changes increment", %{session: session} do
session =
session
|> visit("/plus-minus-svelte")
|> fill_in(Query.css("input[aria-label='Step amount']"), with: "2")
|> click(Query.css("[data-testid='plus-minus-plus']"))
session = wait_for_value(session, "12")
value = session |> find(Query.css("[data-testid='plus-minus-value']"))
assert Wallaby.Element.text(value) == "12"
end
end

View file

@ -0,0 +1,32 @@
defmodule ExampleWeb.FeatureCase do
@moduledoc """
Case for browser E2E tests using Wallaby.
Use `@tag :e2e` on tests and run with: mix test --only e2e
"""
use ExUnit.CaseTemplate
using do
quote do
use Wallaby.DSL
@endpoint ExampleWeb.Endpoint
end
end
setup tags do
# Start Wallaby only when running E2E tests (avoids requiring chromedriver for plain mix test).
# If chromedriver is not installed, setup will fail; run `mix test --exclude e2e` to skip E2E.
{:ok, _} = Application.ensure_all_started(:wallaby)
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Example.Repo)
unless tags[:async] do
Ecto.Adapters.SQL.Sandbox.mode(Example.Repo, {:shared, self()})
end
{:ok, session} = Wallaby.start_session()
on_exit(fn -> Wallaby.end_session(session) end)
{:ok, session: session}
end
end

View file

@ -31,8 +31,14 @@ defmodule LiveSvelte do
attr :id, :string,
default: nil,
doc:
"Optional stable DOM id override. Auto-generated from the component name by default. " <>
"Only needed when the same component appears in a loop or conditionally rendered block."
"Optional stable DOM id override. Auto-generated from the component name and props by " <>
"default. Only needed when auto-detection is insufficient (e.g. two loops with the same component name)."
attr :key, :any,
default: nil,
doc:
"Identity key for stable DOM IDs in loops. When set, the DOM id becomes `name-key`. " <>
"When not set, LiveSvelte auto-detects identity from props (id, key, index, idx keys)."
attr :class, :string,
default: nil,
@ -67,7 +73,7 @@ defmodule LiveSvelte do
dead = assigns.socket == nil or not LiveView.connected?(assigns.socket)
ssr_active = Application.get_env(:live_svelte, :ssr, true)
svelte_id = assigns.id || auto_id(assigns.name)
svelte_id = assigns.id || key_based_id(assigns.name, assigns.key, assigns.props, assigns.__changed__)
if init and ssr_active and assigns.ssr and assigns.loading != [] do
IO.warn(
@ -103,8 +109,6 @@ defmodule LiveSvelte do
|> assign(:ssr_render, ssr_code)
|> assign(:svelte_id, svelte_id)
Process.put(:live_svelte_last_render_time, System.monotonic_time(:microsecond))
~H"""
<.live_json live_json_props={@live_json_props} svelte_id={@svelte_id}>
<script>
@ -150,37 +154,88 @@ defmodule LiveSvelte do
json_library.encode!(props)
end
defp auto_id(name) do
maybe_reset_counters()
# --- Deterministic ID generation ------------------------------------------------
#
# Priority: explicit `key` attr > auto-detected identity from props > counter fallback.
#
# The counter fallback is only safe for components that are NOT inside a
# comprehension where LiveView may skip rendering unchanged items.
defp key_based_id(name, key, _props, _changed) when not is_nil(key) do
"#{name}-#{key}"
end
defp key_based_id(name, nil, props, changed) do
case extract_identity(props) do
nil ->
maybe_reset_id_counters_for_update(changed)
counter_id(name)
identity ->
"#{name}-#{identity}"
end
end
@identity_keys [:id, "id", :key, "key", :index, "index", :idx, "idx"]
defp extract_identity(props) when is_map(props) do
Enum.find_value(@identity_keys, fn k -> Map.get(props, k) end)
end
defp extract_identity(_), do: nil
# Detect new render cycles by tracking the total number of counter-based
# component calls. When the total reaches the expected count from the
# previous render, we know a new render has started and must reset counters
# so ordinal positions produce the same DOM ids. This keeps LiveView from
# replacing nodes and preserves Svelte component instances (and their local state).
defp maybe_reset_id_counters_for_update(nil), do: :ok
defp maybe_reset_id_counters_for_update(_changed) do
total = Process.get(:live_svelte_total_counter, 0)
expected = Process.get(:live_svelte_expected_total, :not_set)
should_reset =
case expected do
:not_set -> total > 0
n -> total >= n
end
if should_reset do
Process.put(:live_svelte_expected_total, total)
for name <- Process.get(:live_svelte_counter_names, []) do
Process.put({:live_svelte_counter, name}, 0)
end
Process.put(:live_svelte_total_counter, 0)
end
:ok
end
# Simple counter for standalone (non-loop) components that lack identity props.
defp counter_id(name) do
Process.put(:live_svelte_counter_names, Enum.uniq([name | Process.get(:live_svelte_counter_names, [])]))
Process.put(:live_svelte_total_counter, Process.get(:live_svelte_total_counter, 0) + 1)
key = {:live_svelte_counter, name}
count = Process.get(key, 0)
Process.put(key, count + 1)
if count == 0, do: name, else: "#{name}-#{count}"
end
defp maybe_reset_counters do
now = System.monotonic_time(:microsecond)
last = Process.get(:live_svelte_last_render_time)
if last != nil and now - last > 1000 do
Process.get_keys()
|> Enum.each(fn
{:live_svelte_counter, _} = k -> Process.delete(k)
_ -> :ok
end)
end
end
@reserved_prop_keys [:__changed__, :__given__, :svelte_opts, :ssr, :class, :socket]
@doc false
def get_props(assigns) do
prop_keys =
assigns
|> Map.get(:__changed__)
|> Map.keys()
case Map.get(assigns, :__changed__) do
nil -> Map.keys(assigns)
changed when is_map(changed) -> Map.keys(changed)
end
assigns
|> Map.filter(fn
{:svelte_opts, _v} -> false
{k, _v} when k in @reserved_prop_keys -> false
{k, _v} -> k in prop_keys
end)
end

View file

@ -1,7 +1,7 @@
defmodule LiveSvelte.MixProject do
use Mix.Project
@version "0.17.3"
@version "0.17.4"
@repo_url "https://github.com/woutdp/live_svelte"
def project do

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "live_svelte",
"version": "0.17.3",
"version": "0.17.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "live_svelte",
"version": "0.17.3",
"version": "0.17.4",
"license": "MIT",
"devDependencies": {
"prettier": "3.3.3",

View file

@ -1,6 +1,6 @@
{
"name": "live_svelte",
"version": "0.17.3",
"version": "0.17.4",
"description": "",
"license": "MIT",
"module": "./priv/static/live_svelte.esm.js",

View file

@ -8,10 +8,11 @@ defmodule LiveSvelte.AutoIdTest do
defp base_assigns(name, opts \\ []) do
%{
__changed__: nil,
__changed__: Keyword.get(opts, :__changed__, nil),
socket: nil,
name: name,
id: Keyword.get(opts, :id),
key: Keyword.get(opts, :key),
props: Keyword.get(opts, :props, %{}),
live_json_props: %{},
ssr: false,
@ -47,13 +48,14 @@ defmodule LiveSvelte.AutoIdTest do
end
defp clear_auto_id_state do
Process.delete(:live_svelte_last_render_time)
Process.get_keys()
|> Enum.each(fn
{:live_svelte_counter, _} = k -> Process.delete(k)
_ -> :ok
end)
Process.delete(:live_svelte_counter_names)
Process.delete(:live_svelte_total_counter)
Process.delete(:live_svelte_expected_total)
end
setup do
@ -63,10 +65,10 @@ defmodule LiveSvelte.AutoIdTest do
end
# ---------------------------------------------------------------------------
# Tests
# Tests — counter fallback (no identity in props)
# ---------------------------------------------------------------------------
describe "auto_id — single component" do
describe "counter fallback — single component without identity props" do
test "uses the bare component name as id" do
html = render_html(base_assigns("Counter"))
assert extract_id(html) == "Counter"
@ -78,9 +80,8 @@ defmodule LiveSvelte.AutoIdTest do
end
end
describe "auto_id — multiple same-name components" do
describe "counter fallback — multiple same-name components without identity props" do
test "first gets bare name, second gets -1 suffix" do
# Call svelte/1 in tight sequence, convert to HTML after
r1 = render_svelte(base_assigns("Counter"))
r2 = render_svelte(base_assigns("Counter"))
@ -95,11 +96,48 @@ defmodule LiveSvelte.AutoIdTest do
assert extract_id(to_html(r3)) == "Counter-2"
end
test "ids are stable across init and update so client state is preserved" do
# Init: two Counter components without key/identity
r1_init = render_svelte(base_assigns("Counter"))
r2_init = render_svelte(base_assigns("Counter"))
assert extract_id(to_html(r1_init)) == "Counter"
assert extract_id(to_html(r2_init)) == "Counter-1"
# Update: same two components (simulates LiveView re-render after server update)
r1_update = render_svelte(base_assigns("Counter", __changed__: %{number: true}))
r2_update = render_svelte(base_assigns("Counter", __changed__: %{number: true}))
assert extract_id(to_html(r1_update)) == "Counter"
assert extract_id(to_html(r2_update)) == "Counter-1"
end
test "ids remain stable across three consecutive updates with identical __changed__" do
# Init render (connected mount)
_r1 = render_svelte(base_assigns("Counter"))
_r2 = render_svelte(base_assigns("Counter"))
# 1st update — counter reset because expected not yet set
r1_u1 = render_svelte(base_assigns("Counter", __changed__: %{props: true}))
r2_u1 = render_svelte(base_assigns("Counter", __changed__: %{props: true}))
assert extract_id(to_html(r1_u1)) == "Counter"
assert extract_id(to_html(r2_u1)) == "Counter-1"
# 2nd update — same __changed__ value; counter must still reset
r1_u2 = render_svelte(base_assigns("Counter", __changed__: %{props: true}))
r2_u2 = render_svelte(base_assigns("Counter", __changed__: %{props: true}))
assert extract_id(to_html(r1_u2)) == "Counter"
assert extract_id(to_html(r2_u2)) == "Counter-1"
# 3rd update — verifies stability holds indefinitely
r1_u3 = render_svelte(base_assigns("Counter", __changed__: %{props: true}))
r2_u3 = render_svelte(base_assigns("Counter", __changed__: %{props: true}))
assert extract_id(to_html(r1_u3)) == "Counter"
assert extract_id(to_html(r2_u3)) == "Counter-1"
end
end
describe "auto_id — different component names" do
describe "counter fallback — different component names" do
test "each name has its own independent counter" do
# Call all three in tight succession so the gap stays under 1ms
r_a = render_svelte(base_assigns("Counter"))
r_b = render_svelte(base_assigns("LogList"))
r_c = render_svelte(base_assigns("Counter"))
@ -110,14 +148,27 @@ defmodule LiveSvelte.AutoIdTest do
end
end
describe "auto_id — explicit id override" do
# ---------------------------------------------------------------------------
# Tests — explicit id override
# ---------------------------------------------------------------------------
describe "explicit id override" do
test "explicit id takes precedence over auto-generated id" do
html = render_html(base_assigns("Counter", id: "my-custom-id"))
assert extract_id(html) == "my-custom-id"
end
test "explicit id takes precedence over key attribute" do
html = render_html(base_assigns("Counter", id: "my-custom-id", key: 42))
assert extract_id(html) == "my-custom-id"
end
test "explicit id takes precedence over identity props" do
html = render_html(base_assigns("Counter", id: "my-custom-id", props: %{index: 5}))
assert extract_id(html) == "my-custom-id"
end
test "explicit id does not consume a counter slot" do
# Explicit id first, then two auto-generated ones
_r1 = render_svelte(base_assigns("Counter", id: "custom"))
r2 = render_svelte(base_assigns("Counter"))
r3 = render_svelte(base_assigns("Counter"))
@ -127,35 +178,127 @@ defmodule LiveSvelte.AutoIdTest do
end
end
describe "counter reset between render cycles" do
test "counters reset after a simulated render-cycle gap" do
# First render cycle — tight sequence
r1 = render_svelte(base_assigns("Counter"))
r2 = render_svelte(base_assigns("Counter"))
# ---------------------------------------------------------------------------
# Tests — key attribute
# ---------------------------------------------------------------------------
assert extract_id(to_html(r1)) == "Counter"
assert extract_id(to_html(r2)) == "Counter-1"
# Simulate time gap between render cycles (>1ms)
Process.sleep(2)
# Second render cycle — counters should reset
r3 = render_svelte(base_assigns("Counter"))
r4 = render_svelte(base_assigns("Counter"))
assert extract_id(to_html(r3)) == "Counter"
assert extract_id(to_html(r4)) == "Counter-1"
describe "key attribute" do
test "key generates name-key id" do
html = render_html(base_assigns("Static", key: 0))
assert extract_id(html) == "Static-0"
end
test "counters do NOT reset within the same render cycle" do
# Rapid successive calls (same render cycle)
results = for _ <- 1..5, do: render_svelte(base_assigns("Widget"))
ids = Enum.map(results, &(to_html(&1) |> extract_id()))
test "key takes precedence over identity props" do
html = render_html(base_assigns("Static", key: "mykey", props: %{index: 99}))
assert extract_id(html) == "Static-mykey"
end
assert ids == ["Widget", "Widget-1", "Widget-2", "Widget-3", "Widget-4"]
test "string key works" do
html = render_html(base_assigns("Card", key: "abc-123"))
assert extract_id(html) == "Card-abc-123"
end
test "key does not consume a counter slot" do
_r1 = render_svelte(base_assigns("Widget", key: 0))
r2 = render_svelte(base_assigns("Widget"))
# Widget without key/identity falls back to counter — gets bare name
assert extract_id(to_html(r2)) == "Widget"
end
end
# ---------------------------------------------------------------------------
# Tests — identity auto-detection from props
# ---------------------------------------------------------------------------
describe "identity from props — :index key" do
test "props with atom :index key generate deterministic id" do
html = render_html(base_assigns("Static", props: %{index: 0, color: "red"}))
assert extract_id(html) == "Static-0"
end
test "each loop item gets a unique stable id" do
results =
for i <- 0..3 do
render_svelte(base_assigns("Static", props: %{index: i, color: "red"}))
end
ids = Enum.map(results, &(to_html(&1) |> extract_id()))
assert ids == ["Static-0", "Static-1", "Static-2", "Static-3"]
end
test "ids are stable even when only one item is rendered (simulates LiveView partial re-render)" do
# First render: items 0, 1, 2
batch1 =
for i <- 0..2 do
render_svelte(base_assigns("Static", props: %{index: i, color: "red"}))
end
ids1 = Enum.map(batch1, &(to_html(&1) |> extract_id()))
assert ids1 == ["Static-0", "Static-1", "Static-2"]
# Simulate LiveView only rendering the NEW item (index 3)
# — this is the scenario that broke the old counter approach
r_new = render_svelte(base_assigns("Static", props: %{index: 3, color: "red"}))
assert extract_id(to_html(r_new)) == "Static-3"
# No conflict with existing ids — "Static-3" is unique
end
end
describe "identity from props — :id key" do
test "props with atom :id key generate deterministic id" do
html = render_html(base_assigns("UserCard", props: %{id: "user-42", name: "Alice"}))
assert extract_id(html) == "UserCard-user-42"
end
end
describe "identity from props — string keys" do
test "props with string \"index\" key generate deterministic id" do
html = render_html(base_assigns("Item", props: %{"index" => 7}))
assert extract_id(html) == "Item-7"
end
test "props with string \"id\" key generate deterministic id" do
html = render_html(base_assigns("Item", props: %{"id" => "abc"}))
assert extract_id(html) == "Item-abc"
end
end
describe "identity from props — priority order" do
test ":id takes precedence over :index" do
html = render_html(base_assigns("Card", props: %{id: "x", index: 5}))
assert extract_id(html) == "Card-x"
end
test ":key in props takes precedence over :index" do
html = render_html(base_assigns("Card", props: %{key: "k", index: 5}))
assert extract_id(html) == "Card-k"
end
end
describe "identity from props — no identity keys" do
test "falls back to counter when props have no identity keys" do
r1 = render_svelte(base_assigns("Chart", props: %{data: [1, 2, 3], type: "line"}))
r2 = render_svelte(base_assigns("Chart", props: %{data: [4, 5, 6], type: "bar"}))
assert extract_id(to_html(r1)) == "Chart"
assert extract_id(to_html(r2)) == "Chart-1"
end
test "falls back to counter when props is empty" do
r1 = render_svelte(base_assigns("Widget"))
r2 = render_svelte(base_assigns("Widget"))
assert extract_id(to_html(r1)) == "Widget"
assert extract_id(to_html(r2)) == "Widget-1"
end
end
# ---------------------------------------------------------------------------
# Tests — live_json and phx-update (unchanged from before)
# ---------------------------------------------------------------------------
describe "live_json id derivation" do
test "live_json div id is prefixed with lj- and uses the svelte id" do
html = render_html(base_assigns("Counter", props: %{x: 1}))
@ -167,11 +310,19 @@ defmodule LiveSvelte.AutoIdTest do
end
describe "phx-update attribute" do
test "outer hook container has phx-update=ignore to prevent DOM morphing" do
test "outer hook container has phx-update=ignore to protect Svelte DOM" do
html = render_html(base_assigns("Counter"))
# The outer div with phx-hook="SvelteHook" must have phx-update="ignore"
# to prevent LiveView from recreating it during DOM diffs
# so LiveView updates the element's attributes (firing the hook's updated()
# callback) but does not morphdom-patch children (preserving Svelte's DOM).
assert html =~ ~r/phx-hook="SvelteHook"[^>]*phx-update="ignore"/
end
test "inner target div does NOT have phx-update=ignore (redundant)" do
html = render_html(base_assigns("Counter"))
# phx-update="ignore" on the outer div already protects all children;
# the inner data-svelte-target div no longer needs its own copy.
refute html =~ ~r/data-svelte-target[^>]*phx-update="ignore"/
end
end
end