Skip to content

Commit 178404f

Browse files
authored
Support aria-label and aria-labelledby in find_by_label/assertions (#319)
Allow form fields to be located by their accessible name provided via `aria-label` or `aria-labelledby`, in addition to `<label>` elements. When a field is reachable by both, the `<label>` match takes precedence. Note that this works for `assertions`. It does not work for `click_link` or `click_button`.
1 parent 50f390c commit 178404f

5 files changed

Lines changed: 240 additions & 16 deletions

File tree

lib/phoenix_test.ex

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,13 @@ defmodule PhoenixTest do
588588
<label for="search" class="sr-only">Search</label>
589589
<input id="search" type="text" name="q" />
590590
```
591+
592+
In addition to `<label>` elements, the label can also be the accessible name
593+
provided by the input's `aria-label` or `aria-labelledby` attribute:
594+
595+
```html
596+
<input type="text" name="q" aria-label="Search" />
597+
```
591598
"""
592599
def fill_in(session, label, opts) when is_binary(label) and is_list(opts) do
593600
opts = Keyword.validate!(opts, [:with, exact: true])
@@ -1367,7 +1374,11 @@ defmodule PhoenixTest do
13671374
Pass `true` to look for a checked field, or `false` to look for an
13681375
unchecked field.
13691376
1370-
- `label`: the label associated to the form field with `value`, `selected`, or `checked`
1377+
- `label`: the label associated to the form field with `value`, `selected`, or
1378+
`checked`. The association can be a `<label>` element (wrapping the field or
1379+
pointing to it via `for`/`id`), or an accessible name provided by the field's
1380+
`aria-label` or `aria-labelledby` attribute. Note: if a field is reachable by
1381+
both a `<label>` and an `aria-*` attribute, the `<label>` match takes precedence.
13711382
13721383
- `exact`: by default `assert_has/3` will perform a substring match (e.g. `a
13731384
=~ b`). That makes it easier to assert text within HTML elements that also
@@ -1506,7 +1517,11 @@ defmodule PhoenixTest do
15061517
Pass `true` to look for a checked field or `false` to look for an
15071518
unchecked field.
15081519
1509-
- `label`: the label associated to the form field with `value`, `selected`, or `checked`
1520+
- `label`: the label associated to the form field with `value`, `selected`, or
1521+
`checked`. The association can be a `<label>` element (wrapping the field or
1522+
pointing to it via `for`/`id`), or an accessible name provided by the field's
1523+
`aria-label` or `aria-labelledby` attribute. Note: if a field is reachable by
1524+
both a `<label>` and an `aria-*` attribute, the `<label>` match takes precedence.
15101525
15111526
- `exact`: by default `refute_has/3` will perform a substring match (e.g. `a
15121527
=~ b`). That makes it easier to refute text within HTML elements that also

lib/phoenix_test/query.ex

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,13 @@ defmodule PhoenixTest.Query do
217217
def find_by_label(html, input_selectors, label, opts \\ [exact: true]) do
218218
input_selectors = List.wrap(input_selectors)
219219

220+
case find_by_label_element(html, input_selectors, label, opts) do
221+
{:found, _element} = found -> found
222+
not_found -> with :not_found <- find_by_aria(html, input_selectors, label, opts), do: not_found
223+
end
224+
end
225+
226+
defp find_by_label_element(html, input_selectors, label, opts) do
220227
case find_labels(html, input_selectors, label, opts) do
221228
{:implicit_association, _label_element, element} ->
222229
{:found, element}
@@ -254,6 +261,62 @@ defmodule PhoenixTest.Query do
254261
end
255262
end
256263

264+
# Finds elements whose accessible name (via `aria-label` or `aria-labelledby`)
265+
# matches `label`. Referenced `aria-labelledby` ids are resolved document-wide.
266+
defp find_by_aria(html, input_selectors, label, opts) do
267+
parsed = Html.parse_fragment(html)
268+
269+
input_selectors
270+
|> Enum.flat_map(fn selector ->
271+
parsed
272+
|> Html.all(selector)
273+
|> Enum.filter(&aria_name_match?(parsed, &1, label, opts))
274+
end)
275+
|> case do
276+
[] -> :not_found
277+
[element] -> {:found, element}
278+
[_first | _rest] = found -> {:not_found, :found_many_labels_with_inputs, [], found}
279+
end
280+
end
281+
282+
defp aria_name_match?(parsed, element, label, opts) do
283+
aria_label_match?(element, label, opts) or aria_labelledby_match?(parsed, element, label, opts)
284+
end
285+
286+
defp aria_label_match?(element, label, opts) do
287+
case Html.attribute(element, "aria-label") do
288+
nil -> false
289+
value -> text_match?(normalize_whitespace(value), label, opts)
290+
end
291+
end
292+
293+
defp aria_labelledby_match?(parsed, element, label, opts) do
294+
case Html.attribute(element, "aria-labelledby") do
295+
nil ->
296+
false
297+
298+
ids ->
299+
text =
300+
ids
301+
|> String.split()
302+
|> Enum.map_join(" ", &labelledby_text(parsed, &1))
303+
|> normalize_whitespace()
304+
305+
text != "" and text_match?(text, label, opts)
306+
end
307+
end
308+
309+
defp labelledby_text(parsed, id) do
310+
case parsed |> Html.all("[id='#{id}']") |> Enum.at(0) do
311+
nil -> ""
312+
element -> Html.element_text(element)
313+
end
314+
end
315+
316+
defp normalize_whitespace(string) do
317+
string |> String.replace(~r/\s+/, " ") |> String.trim()
318+
end
319+
257320
defp find_labels(html, input_selectors, label, opts) do
258321
html
259322
|> find("label", label, opts)
@@ -472,25 +535,18 @@ defmodule PhoenixTest.Query do
472535
end
473536

474537
defp filter_by_element_text(elements, text, opts) do
475-
exact_match = Keyword.get(opts, :exact, false)
476-
477-
filter_fun =
478-
if exact_match do
479-
&(Html.element_text(&1) == text)
480-
else
481-
&(Html.element_text(&1) =~ text)
482-
end
483-
484-
Enum.filter(elements, filter_fun)
538+
Enum.filter(elements, &text_match?(Html.element_text(&1), text, opts))
485539
end
486540

487541
defp find_first_by_element_text(elements, text, opts) do
488-
exact_match = Keyword.get(opts, :exact, false)
542+
Enum.find(elements, &text_match?(Html.element_text(&1), text, opts))
543+
end
489544

490-
if exact_match do
491-
Enum.find(elements, &(Html.element_text(&1) == text))
545+
defp text_match?(subject, text, opts) do
546+
if Keyword.get(opts, :exact, false) do
547+
subject == text
492548
else
493-
Enum.find(elements, &(Html.element_text(&1) =~ text))
549+
subject =~ text
494550
end
495551
end
496552

test/phoenix_test/assertions_test.exs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,37 @@ defmodule PhoenixTest.AssertionsTest do
158158
end
159159
end
160160

161+
test "assert by aria-label", %{conn: conn} do
162+
conn
163+
|> visit("/page/by_value")
164+
|> assert_has("input", label: "Sword")
165+
|> assert_has("input", label: "Sword", value: "Sting")
166+
end
167+
168+
test "assert by aria-labelledby", %{conn: conn} do
169+
conn
170+
|> visit("/page/by_value")
171+
|> assert_has("input", label: "Steed", value: "Shadowfax")
172+
end
173+
174+
test "assert by aria-labelledby with multiple ids", %{conn: conn} do
175+
conn
176+
|> visit("/page/by_value")
177+
|> assert_has("input", label: "Middle Earth", value: "Mordor")
178+
end
179+
180+
test "assert select by aria-label and selected", %{conn: conn} do
181+
conn
182+
|> visit("/page/by_value")
183+
|> assert_has("select", label: "Weapon", selected: "Bow")
184+
end
185+
186+
test "assert checkbox by aria-label and checked", %{conn: conn} do
187+
conn
188+
|> visit("/page/by_value")
189+
|> assert_has(".ranger", label: "Aragorn", checked: true)
190+
end
191+
161192
test "succeeds when select option was selected by an HTML selected attribute", %{conn: conn} do
162193
conn
163194
|> visit("/page/by_value")
@@ -874,6 +905,29 @@ defmodule PhoenixTest.AssertionsTest do
874905
end
875906
end
876907

908+
test "refute by aria label when it doesn't match", %{conn: conn} do
909+
conn
910+
|> visit("/page/by_value")
911+
|> refute_has("input", label: "Sword", value: "Glamdring")
912+
|> refute_has("input", label: "Bow of Galadriel")
913+
end
914+
915+
test "refute by aria-label raises an error if found", %{conn: conn} do
916+
assert_raise AssertionError, fn ->
917+
conn
918+
|> visit("/page/by_value")
919+
|> refute_has("input", label: "Sword")
920+
end
921+
end
922+
923+
test "refute by aria-labelledby raises an error if found", %{conn: conn} do
924+
assert_raise AssertionError, fn ->
925+
conn
926+
|> visit("/page/by_value")
927+
|> refute_has("input", label: "Steed")
928+
end
929+
end
930+
877931
test "can refute a select by unselected option", %{conn: conn} do
878932
conn
879933
|> visit("/page/by_value")

test/phoenix_test/query_test.exs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,82 @@ defmodule PhoenixTest.QueryTest do
742742

743743
assert {"label", [{"for", "wrapped-notes"}], _} = Html.element(element)
744744
end
745+
746+
test "returns {:found, element} matching by aria-label" do
747+
html = """
748+
<input name="search" aria-label="Search" />
749+
"""
750+
751+
assert {:found, element} = Query.find_by_label(html, "input", "Search")
752+
assert {"input", [{"name", "search"}, {"aria-label", "Search"}], []} = Html.element(element)
753+
end
754+
755+
test "returns {:found, element} matching by aria-labelledby" do
756+
html = """
757+
<span id="search-label">Search</span>
758+
<input name="search" aria-labelledby="search-label" />
759+
"""
760+
761+
assert {:found, element} = Query.find_by_label(html, "input", "Search")
762+
assert {"input", [{"name", "search"}, {"aria-labelledby", "search-label"}], []} = Html.element(element)
763+
end
764+
765+
test "returns {:found, element} matching by aria-labelledby with multiple ids" do
766+
html = """
767+
<span id="label-1">Middle</span>
768+
<span id="label-2">Earth</span>
769+
<input name="realm" aria-labelledby="label-1 label-2" />
770+
"""
771+
772+
assert {:found, element} = Query.find_by_label(html, "input", "Middle Earth")
773+
assert {"input", [{"name", "realm"}, {"aria-labelledby", "label-1 label-2"}], []} = Html.element(element)
774+
end
775+
776+
test "aria matching honors exact option" do
777+
html = """
778+
<input name="search" aria-label="Search the archives" />
779+
"""
780+
781+
assert {:found, _} = Query.find_by_label(html, "input", "Search", exact: false)
782+
assert {:not_found, :no_label, _} = Query.find_by_label(html, "input", "Search", exact: true)
783+
end
784+
785+
test "aria matching normalizes whitespace" do
786+
html = """
787+
<input name="search" aria-label=" Search the archives " />
788+
"""
789+
790+
assert {:found, _} = Query.find_by_label(html, "input", "Search the archives", exact: true)
791+
end
792+
793+
test "prefers <label> association over aria-label" do
794+
html = """
795+
<label for="labelled">Search</label>
796+
<input id="labelled" name="labelled" />
797+
<input name="aria" aria-label="Search" />
798+
"""
799+
800+
assert {:found, element} = Query.find_by_label(html, "input", "Search")
801+
assert {"input", [{"id", "labelled"}, {"name", "labelled"}], []} = Html.element(element)
802+
end
803+
804+
test "returns original label failure when aria also misses" do
805+
html = """
806+
<input name="search" aria-label="Something else" />
807+
"""
808+
809+
assert {:not_found, :no_label, _} = Query.find_by_label(html, "input", "Search")
810+
end
811+
812+
test "returns :found_many_labels_with_inputs when multiple elements match via aria" do
813+
html = """
814+
<input name="one" aria-label="Search" />
815+
<input name="two" aria-label="Search" />
816+
"""
817+
818+
assert {:not_found, :found_many_labels_with_inputs, [], inputs} = Query.find_by_label(html, "input", "Search")
819+
assert length(inputs) == 2
820+
end
745821
end
746822

747823
describe "find_ancestor!/3" do

test/support/web_app/page_view.ex

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,29 @@ defmodule PhoenixTest.WebApp.PageView do
445445
Merry
446446
</label>
447447
<input id="merry-checkbox" class="user" type="checkbox" name="users[]" value="merry" checked />
448+
449+
<input type="text" name="sword" value="Sting" aria-label="Sword" />
450+
451+
<span id="steed-label">Steed</span>
452+
<input type="text" name="steed" value="Shadowfax" aria-labelledby="steed-label" />
453+
454+
<span id="realm-label">Middle</span>
455+
<span id="realm-label-2">Earth</span>
456+
<input type="text" name="realm" value="Mordor" aria-labelledby="realm-label realm-label-2" />
457+
458+
<select aria-label="Weapon" name="weapon">
459+
<option value="axe">Axe</option>
460+
<option value="bow" selected>Bow</option>
461+
</select>
462+
463+
<input
464+
class="ranger"
465+
type="checkbox"
466+
name="rangers[]"
467+
value="aragorn"
468+
aria-label="Aragorn"
469+
checked
470+
/>
448471
</form>
449472
"""
450473
end

0 commit comments

Comments
 (0)