mirror of
https://github.com/woutdp/live_svelte
synced 2026-05-24 01:18:53 +00:00
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:
parent
553ea1e466
commit
bfdae993c2
80 changed files with 3161 additions and 130 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
37
README.md
37
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
example_project/assets/package-lock.json
generated
2
example_project/assets/package-lock.json
generated
|
|
@ -26,7 +26,7 @@
|
|||
}
|
||||
},
|
||||
"../..": {
|
||||
"version": "0.17.2",
|
||||
"version": "0.17.4",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"prettier": "3.3.3",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Hello World
|
||||
<span data-testid="hello-world-content">Hello World</span>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
29
example_project/assets/svelte/Static.svelte
Normal file
29
example_project/assets/svelte/Static.svelte
Normal 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>
|
||||
16
example_project/assets/svelte/StaticTest.svelte
Normal file
16
example_project/assets/svelte/StaticTest.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
]
|
||||
},
|
||||
%{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
116
example_project/lib/example_web/live/live_static_color.ex
Normal file
116
example_project/lib/example_web/live/live_static_color.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
16
example_project/test/example_web/hello_world_test.exs
Normal file
16
example_project/test/example_web/hello_world_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
70
example_project/test/example_web/live/live_chat_test.exs
Normal file
70
example_project/test/example_web/live/live_chat_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
66
example_project/test/example_web/live/live_json_test.exs
Normal file
66
example_project/test/example_web/live/live_json_test.exs
Normal 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
|
||||
129
example_project/test/example_web/live/live_lights_test.exs
Normal file
129
example_project/test/example_web/live/live_lights_test.exs
Normal 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
|
||||
73
example_project/test/example_web/live/live_log_list_test.exs
Normal file
73
example_project/test/example_web/live/live_log_list_test.exs
Normal 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
|
||||
101
example_project/test/example_web/live/live_notes_otp_test.exs
Normal file
101
example_project/test/example_web/live/live_notes_otp_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
104
example_project/test/example_web/live/live_sigil_test.exs
Normal file
104
example_project/test/example_web/live/live_sigil_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
30
example_project/test/example_web/live/live_struct_test.exs
Normal file
30
example_project/test/example_web/live/live_struct_test.exs
Normal 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
|
||||
39
example_project/test/example_web/lodash_test.exs
Normal file
39
example_project/test/example_web/lodash_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
72
example_project/test/example_web/plus_minus_svelte_test.exs
Normal file
72
example_project/test/example_web/plus_minus_svelte_test.exs
Normal 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
|
||||
32
example_project/test/support/feature_case.ex
Normal file
32
example_project/test/support/feature_case.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
2
mix.exs
2
mix.exs
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue