From bfdae993c2a2568cdbbee709c0f1bbe2e6e61ef3 Mon Sep 17 00:00:00 2001 From: Denis Donici Date: Wed, 18 Feb 2026 21:30:17 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 19 +- README.md | 37 ++- example_project/README.md | 19 ++ example_project/assets/package-lock.json | 2 +- example_project/assets/svelte/Chat.svelte | 9 +- .../assets/svelte/ClientSideLoading.svelte | 2 +- .../assets/svelte/CounterHybrid.svelte | 5 +- .../assets/svelte/HelloWorld.svelte | 2 +- .../assets/svelte/LightControllers.svelte | 4 +- .../assets/svelte/LightStatusBar.svelte | 2 +- example_project/assets/svelte/LiveJson.svelte | 6 +- example_project/assets/svelte/Lodash.svelte | 4 +- example_project/assets/svelte/LogList.svelte | 5 +- example_project/assets/svelte/NotesApp.svelte | 16 +- .../assets/svelte/PlusMinus.svelte | 6 +- .../assets/svelte/SimpleCounter.svelte | 10 +- example_project/assets/svelte/Slots.svelte | 12 +- example_project/assets/svelte/Static.svelte | 29 ++ .../assets/svelte/StaticTest.svelte | 16 ++ example_project/assets/svelte/Struct.svelte | 6 +- example_project/config/test.exs | 12 +- .../lib/example_web/components/layouts.ex | 3 +- .../controllers/page_html/home.html.heex | 6 + example_project/lib/example_web/endpoint.ex | 15 + .../example_web/live/live_breaking_news.ex | 5 +- .../lib/example_web/live/live_chat.ex | 3 +- .../live/live_client_side_loading.ex | 6 +- .../lib/example_web/live/live_notes_otp.ex | 2 +- .../lib/example_web/live/live_plus_minus.ex | 22 +- .../lib/example_web/live/live_sigil.ex | 10 +- .../example_web/live/live_simple_counter.ex | 16 +- .../example_web/live/live_slots_dynamic.ex | 6 +- .../lib/example_web/live/live_static_color.ex | 116 ++++++++ .../lib/example_web/live/live_struct.ex | 3 +- example_project/lib/example_web/router.ex | 1 + example_project/mix.exs | 8 +- example_project/mix.lock | 12 + .../controllers/page_controller_test.exs | 2 +- .../test/example_web/hello_world_test.exs | 16 ++ .../live/live_breaking_news_test.exs | 116 ++++++++ .../test/example_web/live/live_chat_test.exs | 70 +++++ .../live/live_client_side_loading_test.exs | 60 ++++ .../test/example_web/live/live_json_test.exs | 66 +++++ .../example_web/live/live_lights_test.exs | 129 +++++++++ .../example_web/live/live_log_list_test.exs | 73 +++++ .../example_web/live/live_notes_otp_test.exs | 101 +++++++ .../live/live_plus_minus_hybrid_test.exs | 96 +++++++ .../example_web/live/live_plus_minus_test.exs | 75 +++++ .../test/example_web/live/live_sigil_test.exs | 104 +++++++ .../live/live_simple_counter_test.exs | 260 ++++++++++++++++++ .../live/live_slots_dynamic_test.exs | 59 ++++ .../live/live_slots_simple_test.exs | 24 ++ .../live/live_static_color_test.exs | 80 ++++++ .../example_web/live/live_struct_test.exs | 30 ++ .../test/example_web/lodash_test.exs | 39 +++ .../phoenix_test/hello_world_test.exs | 12 + .../phoenix_test/live_breaking_news_test.exs | 58 ++++ .../phoenix_test/live_chat_test.exs | 57 ++++ .../live_client_side_loading_test.exs | 40 +++ .../phoenix_test/live_json_test.exs | 45 +++ .../phoenix_test/live_lights_test.exs | 115 ++++++++ .../phoenix_test/live_log_list_test.exs | 50 ++++ .../phoenix_test/live_notes_otp_test.exs | 77 ++++++ .../live_plus_minus_hybrid_test.exs | 46 ++++ .../phoenix_test/live_plus_minus_test.exs | 55 ++++ .../phoenix_test/live_sigil_test.exs | 68 +++++ .../phoenix_test/live_simple_counter_test.exs | 133 +++++++++ .../phoenix_test/live_slots_dynamic_test.exs | 49 ++++ .../phoenix_test/live_slots_simple_test.exs | 38 +++ .../phoenix_test/live_static_color_test.exs | 67 +++++ .../phoenix_test/live_struct_test.exs | 36 +++ .../example_web/phoenix_test/lodash_test.exs | 13 + .../phoenix_test/plus_minus_svelte_test.exs | 41 +++ .../example_web/plus_minus_svelte_test.exs | 72 +++++ example_project/test/support/feature_case.ex | 32 +++ lib/live_svelte.ex | 101 +++++-- mix.exs | 2 +- package-lock.json | 4 +- package.json | 2 +- test/auto_id_test.exs | 221 ++++++++++++--- 80 files changed, 3161 insertions(+), 130 deletions(-) create mode 100644 example_project/assets/svelte/Static.svelte create mode 100644 example_project/assets/svelte/StaticTest.svelte create mode 100644 example_project/lib/example_web/live/live_static_color.ex create mode 100644 example_project/test/example_web/hello_world_test.exs create mode 100644 example_project/test/example_web/live/live_breaking_news_test.exs create mode 100644 example_project/test/example_web/live/live_chat_test.exs create mode 100644 example_project/test/example_web/live/live_client_side_loading_test.exs create mode 100644 example_project/test/example_web/live/live_json_test.exs create mode 100644 example_project/test/example_web/live/live_lights_test.exs create mode 100644 example_project/test/example_web/live/live_log_list_test.exs create mode 100644 example_project/test/example_web/live/live_notes_otp_test.exs create mode 100644 example_project/test/example_web/live/live_plus_minus_hybrid_test.exs create mode 100644 example_project/test/example_web/live/live_plus_minus_test.exs create mode 100644 example_project/test/example_web/live/live_sigil_test.exs create mode 100644 example_project/test/example_web/live/live_simple_counter_test.exs create mode 100644 example_project/test/example_web/live/live_slots_dynamic_test.exs create mode 100644 example_project/test/example_web/live/live_slots_simple_test.exs create mode 100644 example_project/test/example_web/live/live_static_color_test.exs create mode 100644 example_project/test/example_web/live/live_struct_test.exs create mode 100644 example_project/test/example_web/lodash_test.exs create mode 100644 example_project/test/example_web/phoenix_test/hello_world_test.exs create mode 100644 example_project/test/example_web/phoenix_test/live_breaking_news_test.exs create mode 100644 example_project/test/example_web/phoenix_test/live_chat_test.exs create mode 100644 example_project/test/example_web/phoenix_test/live_client_side_loading_test.exs create mode 100644 example_project/test/example_web/phoenix_test/live_json_test.exs create mode 100644 example_project/test/example_web/phoenix_test/live_lights_test.exs create mode 100644 example_project/test/example_web/phoenix_test/live_log_list_test.exs create mode 100644 example_project/test/example_web/phoenix_test/live_notes_otp_test.exs create mode 100644 example_project/test/example_web/phoenix_test/live_plus_minus_hybrid_test.exs create mode 100644 example_project/test/example_web/phoenix_test/live_plus_minus_test.exs create mode 100644 example_project/test/example_web/phoenix_test/live_sigil_test.exs create mode 100644 example_project/test/example_web/phoenix_test/live_simple_counter_test.exs create mode 100644 example_project/test/example_web/phoenix_test/live_slots_dynamic_test.exs create mode 100644 example_project/test/example_web/phoenix_test/live_slots_simple_test.exs create mode 100644 example_project/test/example_web/phoenix_test/live_static_color_test.exs create mode 100644 example_project/test/example_web/phoenix_test/live_struct_test.exs create mode 100644 example_project/test/example_web/phoenix_test/lodash_test.exs create mode 100644 example_project/test/example_web/phoenix_test/plus_minus_svelte_test.exs create mode 100644 example_project/test/example_web/plus_minus_svelte_test.exs create mode 100644 example_project/test/support/feature_case.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index ed91de9..36deedf 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/README.md b/README.md index 341697f..333ec99 100644 --- a/README.md +++ b/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-`. +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. diff --git a/example_project/README.md b/example_project/README.md index b50b47b..79f5959 100644 --- a/example_project/README.md +++ b/example_project/README.md @@ -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 diff --git a/example_project/assets/package-lock.json b/example_project/assets/package-lock.json index bdb36ac..8efc543 100644 --- a/example_project/assets/package-lock.json +++ b/example_project/assets/package-lock.json @@ -26,7 +26,7 @@ } }, "../..": { - "version": "0.17.2", + "version": "0.17.4", "license": "MIT", "devDependencies": { "prettier": "3.3.3", diff --git a/example_project/assets/svelte/Chat.svelte b/example_project/assets/svelte/Chat.svelte index 347321f..4138b7b 100644 --- a/example_project/assets/svelte/Chat.svelte +++ b/example_project/assets/svelte/Chat.svelte @@ -28,11 +28,11 @@ >
- + {name}
-
    +
      {#each messages as message (message.id)} {@const me = message.name === name}
    • {message.name}
      -
      +
      {message.body}
    • @@ -51,6 +51,7 @@
      - +
diff --git a/example_project/assets/svelte/ClientSideLoading.svelte b/example_project/assets/svelte/ClientSideLoading.svelte index e5109e5..396f557 100644 --- a/example_project/assets/svelte/ClientSideLoading.svelte +++ b/example_project/assets/svelte/ClientSideLoading.svelte @@ -1 +1 @@ -
This is the component!
+
This is the component!
\ No newline at end of file diff --git a/example_project/assets/svelte/CounterHybrid.svelte b/example_project/assets/svelte/CounterHybrid.svelte index 2333dc2..632c315 100644 --- a/example_project/assets/svelte/CounterHybrid.svelte +++ b/example_project/assets/svelte/CounterHybrid.svelte @@ -16,15 +16,17 @@ phx-click="set_number" value={number - amount} aria-label="Decrease by {amount}" + data-testid="hybrid-plus-minus-minus" > -{amount} - {number} + {number} @@ -37,6 +39,7 @@ bind:value={amount} min="1" aria-label="Step amount" + data-testid="hybrid-plus-minus-step" /> diff --git a/example_project/assets/svelte/HelloWorld.svelte b/example_project/assets/svelte/HelloWorld.svelte index 557db03..0132039 100644 --- a/example_project/assets/svelte/HelloWorld.svelte +++ b/example_project/assets/svelte/HelloWorld.svelte @@ -1 +1 @@ -Hello World +Hello World diff --git a/example_project/assets/svelte/LightControllers.svelte b/example_project/assets/svelte/LightControllers.svelte index 9550d3e..91be2f1 100644 --- a/example_project/assets/svelte/LightControllers.svelte +++ b/example_project/assets/svelte/LightControllers.svelte @@ -12,7 +12,7 @@ Light
@@ -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" > Brightness
- + {brightness > 0 ? `${brightness}%` : "OFF"}
diff --git a/example_project/assets/svelte/LiveJson.svelte b/example_project/assets/svelte/LiveJson.svelte index 692fca8..bbe65e3 100644 --- a/example_project/assets/svelte/LiveJson.svelte +++ b/example_project/assets/svelte/LiveJson.svelte @@ -11,12 +11,12 @@
Key length
-
{keyCount.toLocaleString()}
+
{keyCount.toLocaleString()}
Rough byte size
-
{byteSize.toLocaleString()}
+
{byteSize.toLocaleString()}
- +
diff --git a/example_project/assets/svelte/Lodash.svelte b/example_project/assets/svelte/Lodash.svelte index 308e390..9262740 100644 --- a/example_project/assets/svelte/Lodash.svelte +++ b/example_project/assets/svelte/Lodash.svelte @@ -18,13 +18,13 @@
Unordered -
[{unordered.join(", ")}]
Ordered -
[{ordered.join(", ")}]
diff --git a/example_project/assets/svelte/LogList.svelte b/example_project/assets/svelte/LogList.svelte index 441da00..ccce0d2 100644 --- a/example_project/assets/svelte/LogList.svelte +++ b/example_project/assets/svelte/LogList.svelte @@ -41,6 +41,7 @@
-
    +
      {#each items.slice(0, i) as item (item.id)}
    • @@ -65,7 +66,7 @@ {/each}
    {#if items.length === 0} -
    +
    No entries yet. Add one above or wait for the timer.
    {/if} diff --git a/example_project/assets/svelte/NotesApp.svelte b/example_project/assets/svelte/NotesApp.svelte index 33a913f..ff9adbe 100644 --- a/example_project/assets/svelte/NotesApp.svelte +++ b/example_project/assets/svelte/NotesApp.svelte @@ -113,9 +113,9 @@ Notes ({encoder}) -
    +
    -
    +
    {encoder} JSON encoder @@ -126,6 +126,7 @@ { e.preventDefault() handleSubmit() @@ -139,6 +140,7 @@ Title * Content