test/openagents_web/form_submit_buttons_test.exs

main at 58e6347eeb72 · 2 KB

defmodule OpenAgentsWeb.FormSubmitButtonsTest do
  @moduledoc """
  Every button that is meant to submit a form must say so.

  `OpenAgentsWeb.UI.button/1` defaults to `type="button"`, which is the right
  default -- a button outside a form that submits one by accident is worse than
  one that does nothing. But it means a call site inside a form has to opt in,
  and eight of them had not: creating an issue, a milestone, a label, a
  project, a project item, and two comment forms all rendered a primary button
  that did nothing at all when clicked.

  The suite could not catch it. `render_submit/1` submits the form directly and
  never touches the button, so every one of those flows was covered by a
  passing test while being completely broken in a browser. This test reads the
  templates instead, which is the only place the defect is visible.
  """

  use ExUnit.Case, async: true

  @form_pattern ~r/<\.form\b.*?<\/\.form>/s
  @button_pattern ~r/<\.button\b[^>]*?>/s

  test "no button inside a submitting form is left as type=button" do
    offenders =
      templates()
      |> Enum.flat_map(&offenders_in/1)
      |> Enum.sort()

    assert offenders == [],
           """
           These buttons sit inside a form that submits, but carry no `type`,
           so `UI.button/1` renders them as `type="button"` and clicking them
           does nothing:

           #{Enum.map_join(offenders, "\n", fn {file, tag} -> "  #{file}\n    #{tag}" end)}

           Add `type="submit"`. If the button is meant to run a `phx-click`
           rather than submit, it is already correct -- give it a `phx-click`
           and this test will leave it alone.
           """
  end

  defp templates do
    Path.wildcard("lib/openagents_web/**/*.ex") ++ Path.wildcard("lib/openagents_web/**/*.heex")
  end

  defp offenders_in(path) do
    source = File.read!(path)

    @form_pattern
    |> Regex.scan(source)
    |> Enum.map(&hd/1)
    |> Enum.filter(&submitting?/1)
    |> Enum.flat_map(fn form ->
      @button_pattern
      |> Regex.scan(form)
      |> Enum.map(&hd/1)
      |> Enum.filter(&needs_type?/1)
      |> Enum.map(&{path, squeeze(&1)})
    end)
  end

  # A form with neither a `phx-submit` nor an `action` is not going anywhere,
  # so a button inside it is not a submit button.
  defp submitting?(form),
    do: String.contains?(form, "phx-submit") or String.contains?(form, "action=")

  defp needs_type?(tag) do
    # A `phx-click` button is a control, not a submit.
    # A button rendered as a link is an anchor and submits nothing.
    not String.contains?(tag, "type=") and
      not String.contains?(tag, "phx-click") and
      not Enum.any?(~w(navigate href patch), &String.contains?(tag, &1))
  end

  defp squeeze(tag), do: tag |> String.split() |> Enum.join(" ")
end