Skip to content

Commit ac3e1c6

Browse files
committed
MDL-63567 core: Make \behat_session_trait::find() smarter
In case `\behat_session_trait::find()` finds multiple elements matching the locator, determine the best item to return from the results: * Return the first element with the exact match. * If there's no exact match, return the first element with the name that starts with the locator text. * Otherwise, just return the first element from the results.
1 parent 43c988b commit ac3e1c6

1 file changed

Lines changed: 115 additions & 2 deletions

File tree

lib/behat/classes/behat_session_trait.php

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,86 @@ protected function locate_path($path) {
7171
return 0 !== strpos($path, 'http') ? $starturl . ltrim($path, '/') : $path;
7272
}
7373

74+
/**
75+
* Get the accessible name of a NodeElement.
76+
*
77+
* @param NodeElement $element The element to get the accessible name for.
78+
* @return string|null
79+
*/
80+
protected function get_accessible_name(NodeElement $element): ?string {
81+
// Check the element's aria-labelledby attribute.
82+
if ($element->hasAttribute('aria-labelledby')) {
83+
$labelledby = $element->getAttribute('aria-labelledby');
84+
$ids = explode(' ', trim($labelledby));
85+
$texts = [];
86+
foreach ($ids as $id) {
87+
if ($ref = $this->getSession()->getPage()->find('css', "#$id")) {
88+
$texts[] = trim($ref->getText());
89+
}
90+
}
91+
$calculatedlabel = trim(implode(' ', $texts));
92+
if ($calculatedlabel) {
93+
return $calculatedlabel;
94+
} else {
95+
// Warn developers about empty aria-label attributes to fix potential accessibility issues.
96+
debugging("No accessible name determined from aria-labelledby attribute: " . $element->getOuterHtml());
97+
}
98+
}
99+
100+
// Otherwise, check the element's aria-label attribute.
101+
if ($element->hasAttribute('aria-label')) {
102+
$arialabel = $element->getAttribute('aria-label') ?? '';
103+
$arialabel = trim($arialabel);
104+
if ($arialabel) {
105+
return $arialabel;
106+
} else {
107+
// Warn developers about empty aria-label attributes to fix potential accessibility issues.
108+
debugging("Element has empty aria-label attribute: " . $element->getOuterHtml());
109+
}
110+
}
111+
112+
// Or check for a <label> tag associated with it.
113+
if ($id = $element->getAttribute('id')) {
114+
if ($labeltag = $this->getSession()->getPage()->find('css', "label[for='$id']")) {
115+
return $labeltag->getText();
116+
}
117+
}
118+
119+
// For images, check the element's alt attribute.
120+
if ($element->getTagName() === 'img' && $element->hasAttribute('alt')) {
121+
$alttext = $element->getAttribute('alt') ?? '';
122+
return trim($alttext);
123+
}
124+
125+
// Try to fall back to the element text.
126+
$text = $element->getText() ?? '';
127+
$text = trim($text);
128+
if ($text) {
129+
return $text;
130+
}
131+
132+
// Or fall back to the element value (e.g. buttons, links).
133+
$value = $element->getValue() ?? '';
134+
$value = trim($value);
135+
if ($value) {
136+
return $value;
137+
}
138+
139+
// Or check for a title attribute in the element as a last resort.
140+
if ($element->getAttribute('title')) {
141+
$title = $element->getAttribute('title') ?? '';
142+
$title = trim($title);
143+
if ($title) {
144+
// Using the title attribute for an element's accessible name is discourage. Warn developers know about this.
145+
debugging("Using the title attribute as accessible name is discouraged: " . $element->getOuterHtml());
146+
}
147+
return $title;
148+
}
149+
150+
// An accessible name was not found.
151+
return null;
152+
}
153+
74154
/**
75155
* Returns the first matching element.
76156
*
@@ -88,9 +168,42 @@ protected function find($selector, $locator, $exception = false, $node = false,
88168
return $locator;
89169
}
90170

91-
// Returns the first match.
92171
$items = $this->find_all($selector, $locator, $exception, $node, $timeout);
93-
return count($items) ? reset($items) : null;
172+
173+
// Determine the best item to return when multiple items were found.
174+
$itemsfound = count($items);
175+
[
176+
'selector' => $selector,
177+
'locator' => $locator,
178+
] = $this->normalise_selector($selector, $locator, $node ?: $this->getSession()->getPage());
179+
180+
if ($itemsfound > 1 && !in_array($selector, ['css', 'xpath'])) {
181+
/** @var NodeElement $item */
182+
foreach ($items as $item) {
183+
// Determine the found item's accessible name.
184+
$accessiblename = $this->get_accessible_name($item);
185+
if (empty($accessiblename)) {
186+
debugging("No accessible name found for element: " . $item->getOuterHtml());
187+
continue;
188+
}
189+
190+
if ($accessiblename === $locator) {
191+
// Return the first item that exactly matches the locator string.
192+
return $item;
193+
} else {
194+
if ($selector === 'named_partial' && is_array($locator)) {
195+
$locator = $locator[1];
196+
}
197+
if (core_text::strpos($locator, $accessiblename) === 0) {
198+
// If there's no exact match, return the first item that starts with the locator string.
199+
return $item;
200+
}
201+
}
202+
}
203+
}
204+
205+
// Fall back to the first item found or return null if there were no items found.
206+
return $itemsfound ? reset($items) : null;
94207
}
95208

96209
/**

0 commit comments

Comments
 (0)