test/openagents_web/component_attrs_test.exs

58e6347eeb72 · 2 KB

defmodule OpenAgentsWeb.ComponentAttrsTest do
  @moduledoc """
  Every enumerated component attribute states the values it accepts.

  Phoenix checks a literal attribute at a call site only when the declaration
  carries a `values:` list. Without one it accepts anything, and the failure is
  silent all the way to the browser: `UI.github_login/1` declared
  `attr :size, :atom, default: :md`, `:md` is not one of `button/1`'s sizes, so
  the button rendered `data-size="md"`, matched no size rule, and arrived with
  no height, no padding and no type scale. Nothing warned, no test failed, and
  the control simply looked wrong.

  The rule is narrow and mechanical: an `:atom` attribute is an enumeration, so
  it must say what it enumerates. That is enough to hand the problem to the
  compiler, which then rejects a bad call site by name — and it did
  immediately, catching `graph_node/1` being passed work-item statuses its
  declaration did not admit.

  `:string`, `:boolean`, `:map`, `:list`, `:any` and `:global` are unbounded by
  nature and are not checked.
  """

  use ExUnit.Case, async: true

  # `attr` declarations wrap, so the options are read up to the next `attr`,
  # `slot`, `def`, or blank-line boundary rather than to the end of the line.
  @declaration ~r/^[ \t]*attr :(\w+), :atom\b(?<options>(?:.|\n)*?)(?=\n[ \t]*(?:attr |slot |def |defp |@doc|\n))/m

  test "every atom attribute declares the values it accepts" do
    offenders =
      for path <- component_sources(),
          source = File.read!(path),
          match <- Regex.scan(@declaration, source, capture: :all_but_first, return: :index),
          {offender, line} <- offense(source, match),
          do: {relative(path), line, offender}

    assert offenders == [], """
    These `:atom` attributes do not declare a `values:` list, so Phoenix cannot
    check what call sites pass them and a wrong value fails silently in the
    browser rather than loudly at compile time:

    #{Enum.map_join(offenders, "\n", fn {file, line, name} -> "  #{file}:#{line}  attr :#{name}" end)}

    Add `values: [...]`. If the attribute genuinely accepts any atom, it is not
    an enumeration -- declare it as `:any` and say why in a comment.
    """
  end

  defp offense(source, [{name_start, name_length}, {options_start, options_length}]) do
    name = binary_part(source, name_start, name_length)
    options = binary_part(source, options_start, options_length)

    if String.contains?(options, "values:") do
      []
    else
      [{name, line_of(source, name_start)}]
    end
  end

  defp offense(_source, _match), do: []

  defp component_sources do
    Path.wildcard("lib/openagents_web/**/*.ex")
  end

  defp line_of(source, offset),
    do: source |> binary_part(0, offset) |> String.split("\n") |> length()

  defp relative(path), do: String.replace_leading(path, "lib/openagents_web/", "")
end