lib/surface/live_view_test.ex

defmodule Surface.LiveViewTest do
  @moduledoc """
  Conveniences for testing Surface components.
  """

  alias Surface.Catalogue

  defmodule BlockWrapper do
    @moduledoc false

    use Phoenix.Component

    def render(assigns) do
      ~H[<%= render_slot(@inner_block) %>]
    end
  end

  defmacro __using__(_opts) do
    quote do
      import Phoenix.ConnTest
      import Phoenix.LiveViewTest
      import Phoenix.LiveView.Helpers, only: [live_component: 2, live_component: 3]
      import Surface, only: [sigil_F: 2]
      import Surface.LiveViewTest
      require Phoenix.LiveView.TagEngine
    end
  end

  @doc """
  Render Surface code.

  Use this macro when testing regular rendering of stateless components or live components
  that don't require a parent live view during the tests.

  For tests depending on the existence of a parent live view, e.g. testing events on live
  components and its side-effects, you need to use either `Phoenix.LiveViewTest.live/2` or
  `Phoenix.LiveViewTest.live_isolated/3`.

  ## Example

      html =
        render_surface do
          ~F"\""
          <Link label="user" to="/users/1" />
          "\""
        end

      assert html =~ "\""
            <a href="/users/1">user</a>
            "\""

  """
  defmacro render_surface(do: do_block) do
    clauses =
      quote do
        _ ->
          unquote(do_block)
      end

    inner_block_assigns =
      quote do
        %{
          __slot__: :inner_block,
          inner_block: Phoenix.LiveView.TagEngine.inner_block(:inner_block, do: unquote(clauses))
        }
      end

    render_component_call =
      quote do
        Phoenix.LiveViewTest.render_component(
          &Surface.LiveViewTest.BlockWrapper.render/1,
          %{
            inner_block: unquote(inner_block_assigns),
            __context__: %{}
          }
        )
      end

    if Macro.Env.has_var?(__CALLER__, {:assigns, nil}) do
      quote do
        var!(assigns) = Map.merge(var!(assigns), %{__context__: %{}})
        unquote(render_component_call) |> Surface.LiveViewTest.clean_html()
      end
    else
      quote do
        var!(assigns) = %{__context__: %{}}
        unquote(render_component_call) |> Surface.LiveViewTest.clean_html()
      end
    end
  end

  @doc """
  Compiles Surface code into a new LiveView module.

  This macro should be used sparingly as it always generates and compiles a new module
  on-the-fly, which can potentially slow down your test suite.

  Its main use is when testing compile-time errors/warnings.

  ## Example

      code =
        quote do
          ~F"\""
          <KeywordProp prop="some string"/>
          "\""
        end

      message =
        ~S(code:1: invalid value for property "prop". Expected a :keyword, got: "some string".)

      assert_raise(CompileError, message, fn ->
        compile_surface(code)
      end)

  """
  defmacro compile_surface(code, assigns \\ quote(do: %{})) do
    env = Map.take(__CALLER__, [:function, :module, :line])

    quote do
      ast =
        unquote(__MODULE__).generate_live_view_ast(
          unquote(code),
          unquote(assigns),
          unquote(Macro.escape(env))
        )

      {{:module, module, _, _}, _} = Code.eval_quoted(ast, [], %{__ENV__ | file: "code", line: 0})

      module
    end
  end

  @doc """
  Wraps a test code so it runs using a custom configuration for a given component.

  Tests using this macro should run synchronously. A warning is shown if the test
  case is configured as `async: true`.

  ## Example

      using_config TextInput, default_class: "default_class" do
        html =
          render_surface do
            ~F"\""
            <TextInput/>
            "\""
          end

        assert html =~ ~r/class="default_class"/
      end

  """
  defmacro using_config(component, config, do: block) do
    if Module.get_attribute(__CALLER__.module, :ex_unit_async) do
      message = """
      Using `using_config` with `async: true` might lead to race conditions.

      Please set `async: false` on the test module.
      """

      Surface.IOHelper.warn(message, __CALLER__)
    end

    quote do
      component = unquote(component)
      old_config = Application.get_env(:surface, :components, [])
      value = unquote(config)
      new_config = Keyword.update(old_config, component, value, fn _ -> value end)
      Application.put_env(:surface, :components, new_config)

      try do
        unquote(block)
      after
        Application.put_env(:surface, :components, old_config)
      end
    end
  end

  @doc """
  Registers components that propagates context into its slots.
  """
  def register_propagate_context_to_slots(components) do
    :global.set_lock(__ENV__.function)
    existing_components_config = Application.get_env(:surface, :components, [])

    components_config =
      Enum.map(components, fn
        {mod, fun} -> {mod, fun, propagate_context_to_slots: true}
        mod -> {mod, propagate_context_to_slots: true}
      end)

    Application.put_env(:surface, :components, existing_components_config ++ components_config)
    :global.del_lock(__ENV__.function)
  end

  @doc """
  This macro generates ExUnit test cases for catalogue examples.

  The tests will automatically assert if the example was successfully rendered.

  Pay attention that, by default, the generated tests don't test how the components should look like.
  However, it makes sure the examples are not raising exceptions at runtime, for instance, due to
  changes in the component's API.

  ## Usage

  The `catalogue_test/1` macro accepts a single argument which can one of:

  * A component module (subject), which will generate tests for all examples/playgrounds found
    for that component.
  * The atom `:all`, which will generate tests for all examples/playgrounds found for ALL components
    in the project.

  Keep in mind that you should either use individual `catalogue_test/1` calls for each
  component or use `:all`. Otherwise, you will end up with duplicated tests.

  ### Options

    * `except` - A list of modules that should be excluded. This option only applies when using `:all`.

  ## Examples

  Generating tests for components' examples:

      defmodule MyProject.Components.ButtonTest do
        use MyProject.ConnCase, async: true

        catalogue_test MyProject.Components.Button
      end

  Generating tests for all avaiable components:

      defmodule MyProject.Components.CatalogueTest do
        use MyProject.ConnCase, async: true

        catalogue_test :all
      end

  Generating tests for all avaiable components except `MyComponent`:

      defmodule MyProject.Components.CatalogueTest do
        use MyProject.ConnCase, async: true

        catalogue_test :all, except: [MyComponent]
      end

  """
  defmacro catalogue_test(module_or_all, opts \\ []) do
    module_or_all = Macro.expand(module_or_all, __CALLER__)
    except = Keyword.get(opts, :except, []) |> Enum.map(&Macro.expand(&1, __CALLER__))
    {examples, playgrounds} = get_examples_and_playgrouds(module_or_all, except, __CALLER__)

    playground_tests =
      for view <- playgrounds do
        config = Catalogue.get_config(view)
        title = Keyword.get(config, :title)
        test_name = if title, do: "#{inspect(view)} - #{title}", else: inspect(view)
        file = view.module_info() |> get_in([:compile, :source]) |> to_string()

        quote line: 1 do
          @file unquote(file)
          test unquote(test_name) do
            assert {:ok, _view, html} = live_isolated(build_conn(), unquote(view))
          end
        end
      end

    examples_tests =
      for view <- examples,
          config <- Surface.Catalogue.get_metadata(view).examples_configs do
        func = Keyword.get(config, :func) |> to_string()
        line = Keyword.get(config, :line) || 1
        assert_texts = Keyword.get(config, :assert, []) |> List.wrap()
        test_name = "#{inspect(view)}.#{func}"
        file = view.module_info() |> get_in([:compile, :source]) |> to_string()

        assert_live_ast =
          quote line: line do
            assert {:ok, _view, html} =
                     live_isolated(build_conn(), unquote(view), session: %{"func" => unquote(func)})
          end

        assert_texts_ast =
          for text <- assert_texts do
            quote line: line do
              assert html =~ unquote(text)
            end
          end

        quote do
          @file unquote(file)
          test unquote(test_name) do
            unquote(assert_live_ast)
            unquote(assert_texts_ast)
          end
        end
      end

    playground_tests ++ examples_tests
  end

  @doc false
  def generate_live_view_ast(render_code, props, env) do
    {func, _} = env.function
    module = Module.concat([env.module, String.replace("(#{func}) at line #{env.line}", "/", "_")])

    props_ast =
      for {name, _} <- props do
        quote do
          prop unquote(Macro.var(name, nil)), :any
        end
      end

    quote do
      defmodule unquote(module) do
        use Surface.LiveView

        unquote_splicing(props_ast)

        def render(var!(assigns)) do
          var!(assigns) = Map.merge(var!(assigns), unquote(Macro.escape(props)))
          unquote(render_code)
        end
      end
    end
  end

  @doc false
  def clean_html(html) do
    html
    |> String.replace(~r/\n+/, "\n")
    |> String.replace(~r/\n\s+\n/, "\n")
  end

  defp get_examples_and_playgrouds(module_or_all, except, caller) do
    components =
      case {module_or_all, except} do
        {:all, []} ->
          Surface.components(only_current_project: true)

        {:all, except} ->
          except_components =
            Enum.filter(except, fn module ->
              case Surface.Compiler.Helpers.validate_component_module(module, to_string(module)) do
                {:error, message} ->
                  Surface.IOHelper.warn(message, caller)
                  false

                :ok ->
                  true
              end
            end)

          Surface.components(only_current_project: true)
          |> Enum.filter(fn c -> Surface.Catalogue.get_metadata(c)[:subject] not in except_components end)

        {module, _} when is_atom(module) ->
          case Surface.Compiler.Helpers.validate_component_module(module, to_string(module)) do
            {:error, message} ->
              Surface.IOHelper.warn(message, caller)
              []

            :ok ->
              Surface.components(only_current_project: true)
              |> Enum.filter(fn c -> Surface.Catalogue.get_metadata(c)[:subject] == module end)
          end

        {value, _} ->
          raise(ArgumentError, "catalogue_test/1 expects either a module or `:all`, got #{inspect(value)}")
      end

    %{playground: playgrounds, example: examples} =
      components
      |> Enum.group_by(&Surface.Catalogue.get_metadata(&1)[:type])
      |> Map.put_new(:example, %{})
      |> Map.put_new(:playground, %{})

    {examples, playgrounds}
  end

  @doc false
  defmacro assert_raise_with_line(exception, message, relative_line, function) do
    {:fn, _, [{:->, meta, _}]} = function
    function_start_line = meta[:line]
    expected_line = function_start_line + relative_line
    expected_file = __CALLER__.file |> Path.relative_to_cwd()

    quote do
      unquote(__MODULE__).__assert_raise_with_line__(
        unquote(exception),
        unquote(message),
        unquote(function),
        unquote(expected_file),
        unquote(expected_line)
      )
    end
  end

  @doc false
  def __assert_raise_with_line__(exception, message, function, expected_file, expected_line) do
    {_, stacktrace} = result = assert_raise_stacktrace(exception, message, function)

    location = stacktrace |> List.first() |> elem(3)
    actual_line = Keyword.get(location, :line)
    actual_file = Keyword.get(location, :file) |> List.to_string()

    unless actual_file == expected_file && actual_line == expected_line do
      message =
        "Wrong stacktrace for #{inspect(exception)}\n" <>
          "expected:\n  #{expected_file}:#{expected_line}\n" <>
          "actual:\n" <> "  #{actual_file}:#{actual_line}"

      ExUnit.Assertions.flunk(message)
    end

    result
  end

  defp assert_raise_stacktrace(exception, message, function) when is_function(function) do
    {error, _} = result = assert_raise_stacktrace(exception, function)

    match? =
      cond do
        is_binary(message) -> Exception.message(error) == message
        is_struct(message, Regex) -> Exception.message(error) =~ message
      end

    message =
      "Wrong message for #{inspect(exception)}\n" <>
        "expected:\n  #{inspect(message)}\n" <>
        "actual:\n" <> "  #{inspect(Exception.message(error))}"

    unless match?, do: ExUnit.Assertions.flunk(message)

    result
  end

  defp assert_raise_stacktrace(exception, function) when is_function(function) do
    try do
      function.()
    rescue
      error ->
        name = error.__struct__

        cond do
          name == exception ->
            check_error_message(name, error)
            {error, __STACKTRACE__}

          name == ExUnit.AssertionError ->
            reraise(error, __STACKTRACE__)

          true ->
            message =
              "Expected exception #{inspect(exception)} " <>
                "but got #{inspect(name)} (#{Exception.message(error)})"

            reraise ExUnit.AssertionError, [message: message], __STACKTRACE__
        end
    else
      _ -> ExUnit.Assertions.flunk("Expected exception #{inspect(exception)} but nothing was raised")
    end
  end

  defp check_error_message(module, error) do
    module.message(error)
  catch
    kind, reason ->
      message =
        "Got exception #{inspect(module)} but it failed to produce a message with:\n\n" <>
          Exception.format(kind, reason, __STACKTRACE__)

      ExUnit.Assertions.flunk(message)
  end
end