From 2ce6f09011f1eb242ea7aa371361305bd4f29d75 Mon Sep 17 00:00:00 2001 From: mrdotb Date: Wed, 22 Jan 2025 15:34:47 +0100 Subject: [PATCH] feat: add tests based on the one from live_vue - Add github ci - Add credo --- .github/workflows/tests.yml | 61 +++++++++++++ .gitignore | 4 + README.md | 1 + lib/live_react/reload.ex | 2 +- lib/live_react/test.ex | 144 +++++++++++++++++++++++++++++ mix.exs | 2 + mix.lock | 21 +++-- test/live_react_test.exs | 175 +++++++++++++++++++++++++++++++++++- 8 files changed, 398 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 lib/live_react/test.ex diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..410bb450 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,61 @@ +--- +name: Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +env: + MIX_ENV: test + +jobs: + code_quality_and_tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - elixir: 1.18.1 + erlang: 27.2.0 + name: Elixir v${{ matrix.elixir }}, Erlang v${{ matrix.erlang }} + steps: + - uses: actions/checkout@v4 + + - uses: erlef/setup-beam@v1 + with: + otp-version: ${{ matrix.erlang }} + elixir-version: ${{ matrix.elixir }} + + - name: Retrieve Dependencies Cache + uses: actions/cache@v4 + id: mix-cache + with: + path: | + deps + _build + key: ${{ runner.os }}-${{ matrix.erlang }}-${{ matrix.elixir }}-mix-${{ hashFiles('**/mix.lock') }} + + - name: Install Mix Dependencies + run: mix deps.get + + - name: Check unused dependencies + run: mix deps.unlock --check-unused + + - name: Compile dependencies + run: mix deps.compile + + - name: Check format + run: mix format --check-formatted + + - name: Check application compile warnings + run: mix compile --force --warnings-as-errors + + - name: Check Credo warnings + run: mix credo + + - name: Run tests + run: mix test diff --git a/.gitignore b/.gitignore index 71ecd184..e78dcc49 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ live_react-*.tar # Ignore node_modules /node_modules/ + +# LSP elixir +.elixir_ls/ +.elixir-tools/ diff --git a/README.md b/README.md index 3a08f281..f15f4f2f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +[![Github CI](https://github.com/mrdotb/live_react/workflows/Tests/badge.svg)](https://github.com/mrdotb/live_react/actions) [![Hex.pm](https://img.shields.io/hexpm/v/live_react.svg)](https://hex.pm/packages/live_react) [![Hexdocs.pm](https://img.shields.io/badge/docs-hexdocs.pm-purple)](https://hexdocs.pm/live_react) [![GitHub](https://img.shields.io/github/stars/mrdotb/live_react?style=social)](https://github.com/mrdotb/live_react) diff --git a/lib/live_react/reload.ex b/lib/live_react/reload.ex index d281dbde..d2adbbe8 100644 --- a/lib/live_react/reload.ex +++ b/lib/live_react/reload.ex @@ -27,7 +27,7 @@ defmodule LiveReact.Reload do ) ) - # TODO - maybe make it configurable in other way than by presence of vite_host config? + # maybe make it configurable in other way than by presence of vite_host config? # https://vitejs.dev/guide/backend-integration.html ~H""" <%= if Application.get_env(:live_react, :vite_host) do %> diff --git a/lib/live_react/test.ex b/lib/live_react/test.ex new file mode 100644 index 00000000..8c35fefd --- /dev/null +++ b/lib/live_react/test.ex @@ -0,0 +1,144 @@ +defmodule LiveReact.Test do + @moduledoc """ + Helpers for testing LiveReact components and views. + + ## Overview + + LiveReact testing differs from traditional Phoenix LiveView testing in how components + are rendered and inspected: + + * In Phoenix LiveView testing, you use `Phoenix.LiveViewTest.render_component/2` + to get the final rendered HTML + * In LiveReact testing, `render_component/2` returns an unrendered LiveReact root + element containing the React component's configuration + + This module provides helpers to extract and inspect React component data from the + LiveReact root element, including: + + * Component name and ID + * Props passed to the component + * Event handlers and their operations + * Server-side rendering (SSR) status + * Slot content + * CSS classes + + ## Examples + + # Render a LiveReact component and inspect its properties + {:ok, view, _html} = live(conn, "/") + react = LiveReact.Test.get_react(view) + + # Basic component info + assert react.component == "MyComponent" + assert react.props["title"] == "Hello" + + # Event handlers + assert react.handlers["click"] == JS.push("click") + + # SSR status and styling + assert react.ssr == true + assert react.class == "my-custom-class" + """ + + @compile {:no_warn_undefined, Floki} + + @doc """ + Extracts React component information from a LiveView or HTML string. + + When multiple React components are present, you can specify which one to extract using + either the `:name` or `:id` option. + + Returns a map containing the component's configuration: + * `:component` - The React component name (from `v-component` attribute) + * `:id` - The unique component identifier (auto-generated or explicitly set) + * `:props` - The decoded props passed to the component + * `:handlers` - Map of event handlers (`v-on:*`) and their operations + * `:slots` - Base64 encoded slot content + * `:ssr` - Boolean indicating if server-side rendering was performed + * `:class` - CSS classes applied to the component root element + + ## Options + * `:name` - Find component by name (from `v-component` attribute) + * `:id` - Find component by ID + + ## Examples + + # From a LiveView, get first React component + {:ok, view, _html} = live(conn, "/") + react = LiveReact.Test.get_react(view) + + # Get specific component by name + react = LiveReact.Test.get_react(view, name: "MyComponent") + + # Get specific component by ID + react = LiveReact.Test.get_react(view, id: "my-component-1") + """ + def get_react(view, opts \\ []) + + def get_react(view, opts) when is_struct(view, Phoenix.LiveViewTest.View) do + view |> Phoenix.LiveViewTest.render() |> get_react(opts) + end + + def get_react(html, opts) when is_binary(html) do + if Code.ensure_loaded?(Floki) do + react = + html + |> Floki.parse_document!() + |> Floki.find("[phx-hook='ReactHook']") + |> find_component!(opts) + + %{ + props: Jason.decode!(attr(react, "data-props")), + component: attr(react, "data-name"), + id: attr(react, "id"), + slots: extract_base64_slots(attr(react, "data-slots")), + ssr: if(is_nil(attr(react, "data-ssr")), do: false, else: true), + class: attr(react, "class") + } + else + raise "Floki is not installed. Add {:floki, \">= 0.30.0\", only: :test} to your dependencies to use LiveReact.Test" + end + end + + defp extract_base64_slots(slots) do + slots + |> Jason.decode!() + |> Enum.map(fn {key, value} -> {key, Base.decode64!(value)} end) + |> Enum.into(%{}) + end + + defp find_component!(components, opts) do + available = Enum.map_join(components, ", ", &"#{attr(&1, "data-name")}##{attr(&1, "id")}") + + components = + Enum.reduce(opts, components, fn + {:id, id}, result -> + with [] <- Enum.filter(result, &(attr(&1, "id") == id)) do + raise "No React component found with id=\"#{id}\". Available components: #{available}" + end + + {:name, name}, result -> + with [] <- Enum.filter(result, &(attr(&1, "data-name") == name)) do + raise "No React component found with name=\"#{name}\". Available components: #{available}" + end + + {key, _}, _result -> + raise ArgumentError, "invalid keyword option for get_react/2: #{key}" + end) + + case components do + [react | _] -> + react + + [] -> + raise "No React components found in the rendered HTML" + end + end + + defp attr(element, name) do + case Floki.attribute(element, name) do + [value] -> value + [] -> nil + end + end +end diff --git a/mix.exs b/mix.exs index 938a3b38..617faeef 100644 --- a/mix.exs +++ b/mix.exs @@ -37,10 +37,12 @@ defmodule LiveReact.MixProject do [ {:jason, "~> 1.2"}, {:nodejs, "~> 3.1", optional: true}, + {:floki, ">= 0.30.0", optional: true}, {:phoenix, ">= 1.7.0"}, {:phoenix_html, ">= 3.3.1"}, {:phoenix_live_view, ">= 0.18.0"}, {:telemetry, "~> 0.4 or ~> 1.0"}, + {:credo, "~> 1.7", only: [:dev, :test]}, {:ex_doc, "~> 0.19", only: :dev, runtime: false}, {:git_ops, "~> 2.6.1", only: [:dev]} ] diff --git a/mix.lock b/mix.lock index 7963dbea..7ea054d5 100644 --- a/mix.lock +++ b/mix.lock @@ -1,20 +1,23 @@ %{ - "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, - "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, - "ex_doc": {:hex, :ex_doc, "0.35.1", "de804c590d3df2d9d5b8aec77d758b00c814b356119b3d4455e4b8a8687aecaf", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2121c6402c8d44b05622677b761371a759143b958c6c19f6558ff64d0aed40df"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "castore": {:hex, :castore, "1.0.11", "4bbd584741601eb658007339ea730b082cc61f3554cf2e8f39bf693a11b49073", [:mix], [], "hexpm", "e03990b4db988df56262852f20de0f659871c35154691427a5047f4967a16a62"}, + "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.42", "f23d856f41919f17cd06a493923a722d87a2d684f143a1e663c04a2b93100682", [:mix], [], "hexpm", "6915b6ca369b5f7346636a2f41c6a6d78b5af419d61a611079189233358b8b8b"}, + "ex_doc": {:hex, :ex_doc, "0.36.1", "4197d034f93e0b89ec79fac56e226107824adcce8d2dd0a26f5ed3a95efc36b1", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d7d26a7cf965dacadcd48f9fa7b5953d7d0cfa3b44fa7a65514427da44eafd89"}, + "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, + "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, "git_ops": {:hex, :git_ops, "2.6.3", "38c6e381b8281b86e2911fa39bea4eab2d171c86d7428786566891efb73b68c3", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a81cb6c6a2a026a4d48cb9a2e1dfca203f9283a3a70aa0c7bc171970c44f23f8"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.0", "74bb8348c9b3a51d5c589bf5aebb0466a84b33274150e3b6ece1da45584afc82", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "49159b7d7d999e836bedaf09dcf35ca18b312230cf901b725a64f3f42e407983"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nodejs": {:hex, :nodejs, "3.1.2", "a4dc114102782dcdc5fc13989398d19e594dd4e3a69181c8a8d54d08f4f950a4", [: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", "7b01eee72c16d919b122f7096918abe9d6062d3cc299afe694e72412bbe90c92"}, - "phoenix": {:hex, :phoenix, "1.7.17", "2fcdceecc6fb90bec26fab008f96abbd0fd93bc9956ec7985e5892cf545152ca", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {: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", "50e8ad537f3f7b0efb1509b2f75b5c918f697be6a45d48e49a30d3b7c0e464c9"}, - "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.0", "3a10dfce8f87b2ad4dc65de0732fc2a11e670b2779a19e8d3281f4619a85bce4", [: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", [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", "254caef0028765965ca6bd104cc7d68dcc7d57cc42912bef92f6b03047251d99"}, + "phoenix": {:hex, :phoenix, "1.7.18", "5310c21443514be44ed93c422e15870aef254cf1b3619e4f91538e7529d2b2e4", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {: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", "1797fcc82108442a66f2c77a643a62980f342bfeb63d6c9a515ab8294870004e"}, + "phoenix_html": {:hex, :phoenix_html, "4.2.0", "83a4d351b66f472ebcce242e4ae48af1b781866f00ef0eb34c15030d4e2069ac", [:mix], [], "hexpm", "9713b3f238d07043583a94296cc4bbdceacd3b3a6c74667f4df13971e7866ec8"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.2", "e7b1dd68c86326e2c45cc81da41e332cc8aa7228a7161e2c811dcd7f1dd14db1", [: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", [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", "8a40265b0cd7d3a35f136dfa3cc048e3b198fc3718763411a78c323a44ebebee"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "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"}, "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [: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", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, diff --git a/test/live_react_test.exs b/test/live_react_test.exs index ac82863d..a9ed81f4 100644 --- a/test/live_react_test.exs +++ b/test/live_react_test.exs @@ -1,8 +1,179 @@ defmodule LiveReactTest do use ExUnit.Case + + import LiveReact + import Phoenix.Component + import Phoenix.LiveViewTest + + alias LiveReact.Test + doctest LiveReact - test "greets the world" do - assert LiveReact.hello() == :world + describe "basic component rendering" do + def simple_component(assigns) do + ~H""" + <.react name="MyComponent" firstName="john" lastName="doe" /> + """ + end + + test "renders component with correct props" do + html = render_component(&simple_component/1) + react = Test.get_react(html) + + assert react.component == "MyComponent" + assert react.props == %{"firstName" => "john", "lastName" => "doe"} + end + + test "generates consistent ID" do + html = render_component(&simple_component/1) + react = Test.get_react(html) + + assert react.id =~ ~r/MyComponent-\d+/ + end + end + + describe "multiple components" do + def multi_component(assigns) do + ~H""" +
+ <.react id="profile-1" firstName="John" name="UserProfile" /> + <.react id="card-1" firstName="Jane" name="UserCard" /> +
+ """ + end + + test "finds first component by default" do + html = render_component(&multi_component/1) + react = Test.get_react(html) + + assert react.component == "UserProfile" + assert react.props == %{"firstName" => "John"} + end + + test "finds specific component by name" do + html = render_component(&multi_component/1) + react = Test.get_react(html, name: "UserCard") + + assert react.component == "UserCard" + assert react.props == %{"firstName" => "Jane"} + end + + test "finds specific component by id" do + html = render_component(&multi_component/1) + react = Test.get_react(html, id: "card-1") + + assert react.component == "UserCard" + assert react.id == "card-1" + end + + test "raises error when component with name not found" do + html = render_component(&multi_component/1) + + assert_raise RuntimeError, + ~r/No React component found with name="Unknown".*Available components: UserProfile#profile-1, UserCard#card-1/, + fn -> + Test.get_react(html, name: "Unknown") + end + end + + test "raises error when component with id not found" do + html = render_component(&multi_component/1) + + assert_raise RuntimeError, + ~r/No React component found with id="unknown-id".*Available components: UserProfile#profile-1, UserCard#card-1/, + fn -> + Test.get_react(html, id: "unknown-id") + end + end + end + + describe "styling" do + def styled_component(assigns) do + ~H""" + <.react name="MyComponent" class="bg-blue-500 rounded" /> + """ + end + + test "applies CSS classes" do + html = render_component(&styled_component/1) + react = Test.get_react(html) + + assert react.class == "bg-blue-500 rounded" + end + end + + describe "SSR behavior" do + def ssr_component(assigns) do + ~H""" + <.react name="MyComponent" ssr={false} /> + """ + end + + test "respects SSR flag" do + html = render_component(&ssr_component/1) + react = Test.get_react(html) + + assert react.ssr == false + end + end + + describe "slots" do + def component_with_named_slot(assigns) do + ~H""" + <.react name="WithSlots"> + <:hello>Simple content + + """ + end + + def component_with_inner_block(assigns) do + ~H""" + <.react name="WithSlots"> + Simple content + + """ + end + + test "warns about usage of named slot" do + assert_raise RuntimeError, + "Unsupported slot: hello, only one default slot is supported, passed as React children.", + fn -> render_component(&component_with_named_slot/1) end + end + + test "renders default slot with inner_block" do + html = render_component(&component_with_inner_block/1) + react = Test.get_react(html) + + assert react.slots == %{"default" => "Simple content"} + end + + test "encodes slot as base64" do + html = render_component(&component_with_inner_block/1) + + # Get raw data-slots attribute to verify base64 encoding + doc = Floki.parse_fragment!(html) + slots_attr = Floki.attribute(doc, "data-slots") + + slots = + slots_attr + |> Jason.decode!() + |> Enum.map(fn {key, value} -> {key, Base.decode64!(value)} end) + |> Enum.into(%{}) + + assert slots == %{"default" => "Simple content"} + end + + test "handles empty slots" do + html = + render_component(fn assigns -> + ~H""" + <.react name="WithSlots" /> + """ + end) + + react = Test.get_react(html) + + assert react.slots == %{} + end end end