Skip to content

Commit 7637399

Browse files
authored
cherry-pick(#30636): fix(role): extract tagName safely (#30639)
Fixes #30616.
1 parent 9e091e7 commit 7637399

File tree

3 files changed

+58
-36
lines changed

3 files changed

+58
-36
lines changed

packages/playwright-core/src/server/injected/domUtils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,12 @@ export function isVisibleTextNode(node: Text) {
125125
const rect = range.getBoundingClientRect();
126126
return rect.width > 0 && rect.height > 0;
127127
}
128+
129+
export function elementSafeTagName(element: Element) {
130+
// Named inputs, e.g. <input name=tagName>, will be exposed as fields on the parent <form>
131+
// and override its properties.
132+
if (element instanceof HTMLFormElement)
133+
return 'FORM';
134+
// Elements from the svg namespace do not have uppercase tagName right away.
135+
return element.tagName.toUpperCase();
136+
}

packages/playwright-core/src/server/injected/roleUtils.ts

Lines changed: 37 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { closestCrossShadow, enclosingShadowRootOrDocument, getElementComputedStyle, isElementStyleVisibilityVisible, isVisibleTextNode, parentElementOrShadowHost } from './domUtils';
17+
import { closestCrossShadow, elementSafeTagName, enclosingShadowRootOrDocument, getElementComputedStyle, isElementStyleVisibilityVisible, isVisibleTextNode, parentElementOrShadowHost } from './domUtils';
1818

1919
function hasExplicitAccessibleName(e: Element) {
2020
return e.hasAttribute('aria-label') || e.hasAttribute('aria-labelledby');
@@ -70,7 +70,7 @@ function isFocusable(element: Element) {
7070
}
7171

7272
function isNativelyFocusable(element: Element) {
73-
const tagName = element.tagName.toUpperCase();
73+
const tagName = elementSafeTagName(element);
7474
if (['BUTTON', 'DETAILS', 'SELECT', 'TEXTAREA'].includes(tagName))
7575
return true;
7676
if (tagName === 'A' || tagName === 'AREA')
@@ -124,7 +124,7 @@ const kImplicitRoleByTagName: { [tagName: string]: (e: Element) => string | null
124124
if (['email', 'tel', 'text', 'url', ''].includes(type)) {
125125
// https://html.spec.whatwg.org/multipage/input.html#concept-input-list
126126
const list = getIdRefs(e, e.getAttribute('list'))[0];
127-
return (list && list.tagName === 'DATALIST') ? 'combobox' : 'textbox';
127+
return (list && elementSafeTagName(list) === 'DATALIST') ? 'combobox' : 'textbox';
128128
}
129129
if (type === 'hidden')
130130
return '';
@@ -201,17 +201,16 @@ const kPresentationInheritanceParents: { [tagName: string]: string[] } = {
201201
};
202202

203203
function getImplicitAriaRole(element: Element): string | null {
204-
// Elements from the svg namespace do not have uppercase tagName.
205-
const implicitRole = kImplicitRoleByTagName[element.tagName.toUpperCase()]?.(element) || '';
204+
const implicitRole = kImplicitRoleByTagName[elementSafeTagName(element)]?.(element) || '';
206205
if (!implicitRole)
207206
return null;
208207
// Inherit presentation role when required.
209208
// https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none
210209
let ancestor: Element | null = element;
211210
while (ancestor) {
212211
const parent = parentElementOrShadowHost(ancestor);
213-
const parents = kPresentationInheritanceParents[ancestor.tagName];
214-
if (!parents || !parent || !parents.includes(parent.tagName))
212+
const parents = kPresentationInheritanceParents[elementSafeTagName(ancestor)];
213+
if (!parents || !parent || !parents.includes(elementSafeTagName(parent)))
215214
break;
216215
const parentExplicitRole = getExplicitAriaRole(parent);
217216
if ((parentExplicitRole === 'none' || parentExplicitRole === 'presentation') && !hasPresentationConflictResolution(parent, parentExplicitRole))
@@ -267,7 +266,7 @@ function getAriaBoolean(attr: string | null) {
267266
// `Any descendants of elements that have the characteristic "Children Presentational: True"`
268267
// https://www.w3.org/TR/wai-aria-1.2/#aria-hidden
269268
export function isElementHiddenForAria(element: Element): boolean {
270-
if (['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(element.tagName))
269+
if (['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(elementSafeTagName(element)))
271270
return true;
272271
const style = getElementComputedStyle(element);
273272
const isSlot = element.nodeName === 'SLOT';
@@ -527,6 +526,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
527526
}
528527

529528
const role = getAriaRole(element) || '';
529+
const tagName = elementSafeTagName(element);
530530

531531
// step 2c:
532532
// if the current node is a control embedded within the label (e.g. any element directly referenced by aria-labelledby) for another widget...
@@ -542,22 +542,22 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
542542
if (!isOwnLabel && !isOwnLabelledBy) {
543543
if (role === 'textbox') {
544544
options.visitedElements.add(element);
545-
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA')
545+
if (tagName === 'INPUT' || tagName === 'TEXTAREA')
546546
return (element as HTMLInputElement | HTMLTextAreaElement).value;
547547
return element.textContent || '';
548548
}
549549
if (['combobox', 'listbox'].includes(role)) {
550550
options.visitedElements.add(element);
551551
let selectedOptions: Element[];
552-
if (element.tagName === 'SELECT') {
552+
if (tagName === 'SELECT') {
553553
selectedOptions = [...(element as HTMLSelectElement).selectedOptions];
554554
if (!selectedOptions.length && (element as HTMLSelectElement).options.length)
555555
selectedOptions.push((element as HTMLSelectElement).options[0]);
556556
} else {
557557
const listbox = role === 'combobox' ? queryInAriaOwned(element, '*').find(e => getAriaRole(e) === 'listbox') : element;
558558
selectedOptions = listbox ? queryInAriaOwned(listbox, '[aria-selected="true"]').filter(e => getAriaRole(e) === 'option') : [];
559559
}
560-
if (!selectedOptions.length && element.tagName === 'INPUT') {
560+
if (!selectedOptions.length && tagName === 'INPUT') {
561561
// SPEC DIFFERENCE:
562562
// This fallback is not explicitly mentioned in the spec, but all browsers and
563563
// wpt test name_heading-combobox-focusable-alternative-manual.html do this.
@@ -596,7 +596,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
596596
// Spec says to ignore this when aria-labelledby is defined.
597597
// WebKit follows the spec, while Chromium and Firefox do not.
598598
// We align with Chromium and Firefox here.
599-
if (element.tagName === 'INPUT' && ['button', 'submit', 'reset'].includes((element as HTMLInputElement).type)) {
599+
if (tagName === 'INPUT' && ['button', 'submit', 'reset'].includes((element as HTMLInputElement).type)) {
600600
options.visitedElements.add(element);
601601
const value = (element as HTMLInputElement).value || '';
602602
if (trimFlatString(value))
@@ -613,7 +613,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
613613
//
614614
// SPEC DIFFERENCE.
615615
// Spec says to ignore this when aria-labelledby is defined, but all browsers take it into account.
616-
if (element.tagName === 'INPUT' && (element as HTMLInputElement).type === 'image') {
616+
if (tagName === 'INPUT' && (element as HTMLInputElement).type === 'image') {
617617
options.visitedElements.add(element);
618618
const labels = (element as HTMLInputElement).labels || [];
619619
if (labels.length && !options.embeddedInLabelledBy)
@@ -630,7 +630,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
630630
}
631631

632632
// https://w3c.github.io/html-aam/#button-element-accessible-name-computation
633-
if (!labelledBy && element.tagName === 'BUTTON') {
633+
if (!labelledBy && tagName === 'BUTTON') {
634634
options.visitedElements.add(element);
635635
const labels = (element as HTMLButtonElement).labels || [];
636636
if (labels.length)
@@ -639,7 +639,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
639639
}
640640

641641
// https://w3c.github.io/html-aam/#output-element-accessible-name-computation
642-
if (!labelledBy && element.tagName === 'OUTPUT') {
642+
if (!labelledBy && tagName === 'OUTPUT') {
643643
options.visitedElements.add(element);
644644
const labels = (element as HTMLOutputElement).labels || [];
645645
if (labels.length)
@@ -652,13 +652,13 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
652652
// For "other form elements", we count select and any other input.
653653
//
654654
// Note: WebKit does not follow the spec and uses placeholder when aria-labelledby is present.
655-
if (!labelledBy && (element.tagName === 'TEXTAREA' || element.tagName === 'SELECT' || element.tagName === 'INPUT')) {
655+
if (!labelledBy && (tagName === 'TEXTAREA' || tagName === 'SELECT' || tagName === 'INPUT')) {
656656
options.visitedElements.add(element);
657657
const labels = (element as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement)).labels || [];
658658
if (labels.length)
659659
return getAccessibleNameFromAssociatedLabels(labels, options);
660660

661-
const usePlaceholder = (element.tagName === 'INPUT' && ['text', 'password', 'search', 'tel', 'email', 'url'].includes((element as HTMLInputElement).type)) || element.tagName === 'TEXTAREA';
661+
const usePlaceholder = (tagName === 'INPUT' && ['text', 'password', 'search', 'tel', 'email', 'url'].includes((element as HTMLInputElement).type)) || tagName === 'TEXTAREA';
662662
const placeholder = element.getAttribute('placeholder') || '';
663663
const title = element.getAttribute('title') || '';
664664
if (!usePlaceholder || title)
@@ -667,10 +667,10 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
667667
}
668668

669669
// https://w3c.github.io/html-aam/#fieldset-and-legend-elements
670-
if (!labelledBy && element.tagName === 'FIELDSET') {
670+
if (!labelledBy && tagName === 'FIELDSET') {
671671
options.visitedElements.add(element);
672672
for (let child = element.firstElementChild; child; child = child.nextElementSibling) {
673-
if (child.tagName === 'LEGEND') {
673+
if (elementSafeTagName(child) === 'LEGEND') {
674674
return getTextAlternativeInternal(child, {
675675
...childOptions,
676676
embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) },
@@ -682,10 +682,10 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
682682
}
683683

684684
// https://w3c.github.io/html-aam/#figure-and-figcaption-elements
685-
if (!labelledBy && element.tagName === 'FIGURE') {
685+
if (!labelledBy && tagName === 'FIGURE') {
686686
options.visitedElements.add(element);
687687
for (let child = element.firstElementChild; child; child = child.nextElementSibling) {
688-
if (child.tagName === 'FIGCAPTION') {
688+
if (elementSafeTagName(child) === 'FIGCAPTION') {
689689
return getTextAlternativeInternal(child, {
690690
...childOptions,
691691
embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) },
@@ -700,7 +700,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
700700
//
701701
// SPEC DIFFERENCE.
702702
// Spec says to ignore this when aria-labelledby is defined, but all browsers take it into account.
703-
if (element.tagName === 'IMG') {
703+
if (tagName === 'IMG') {
704704
options.visitedElements.add(element);
705705
const alt = element.getAttribute('alt') || '';
706706
if (trimFlatString(alt))
@@ -710,10 +710,10 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
710710
}
711711

712712
// https://w3c.github.io/html-aam/#table-element
713-
if (element.tagName === 'TABLE') {
713+
if (tagName === 'TABLE') {
714714
options.visitedElements.add(element);
715715
for (let child = element.firstElementChild; child; child = child.nextElementSibling) {
716-
if (child.tagName === 'CAPTION') {
716+
if (elementSafeTagName(child) === 'CAPTION') {
717717
return getTextAlternativeInternal(child, {
718718
...childOptions,
719719
embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) },
@@ -731,7 +731,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
731731
}
732732

733733
// https://w3c.github.io/html-aam/#area-element
734-
if (element.tagName === 'AREA') {
734+
if (tagName === 'AREA') {
735735
options.visitedElements.add(element);
736736
const alt = element.getAttribute('alt') || '';
737737
if (trimFlatString(alt))
@@ -741,18 +741,18 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
741741
}
742742

743743
// https://www.w3.org/TR/svg-aam-1.0/#mapping_additional_nd
744-
if (element.tagName.toUpperCase() === 'SVG' || (element as SVGElement).ownerSVGElement) {
744+
if (tagName === 'SVG' || (element as SVGElement).ownerSVGElement) {
745745
options.visitedElements.add(element);
746746
for (let child = element.firstElementChild; child; child = child.nextElementSibling) {
747-
if (child.tagName.toUpperCase() === 'TITLE' && (child as SVGElement).ownerSVGElement) {
747+
if (elementSafeTagName(child) === 'TITLE' && (child as SVGElement).ownerSVGElement) {
748748
return getTextAlternativeInternal(child, {
749749
...childOptions,
750750
embeddedInLabelledBy: { element: child, hidden: isElementHiddenForAria(child) },
751751
});
752752
}
753753
}
754754
}
755-
if ((element as SVGElement).ownerSVGElement && element.tagName.toUpperCase() === 'A') {
755+
if ((element as SVGElement).ownerSVGElement && tagName === 'A') {
756756
const title = element.getAttribute('xlink:title') || '';
757757
if (trimFlatString(title)) {
758758
options.visitedElements.add(element);
@@ -762,7 +762,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
762762
}
763763

764764
// See https://w3c.github.io/html-aam/#summary-element-accessible-name-computation for "summary"-specific check.
765-
const shouldNameFromContentForSummary = element.tagName === 'SUMMARY' && !['presentation', 'none'].includes(role);
765+
const shouldNameFromContentForSummary = tagName === 'SUMMARY' && !['presentation', 'none'].includes(role);
766766

767767
// step 2f + step 2h.
768768
if (allowsNameFromContent(role, options.embeddedInTargetElement === 'descendant') ||
@@ -815,7 +815,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
815815
}
816816

817817
// step 2i.
818-
if (!['presentation', 'none'].includes(role) || element.tagName === 'IFRAME') {
818+
if (!['presentation', 'none'].includes(role) || tagName === 'IFRAME') {
819819
options.visitedElements.add(element);
820820
const title = element.getAttribute('title') || '';
821821
if (trimFlatString(title))
@@ -830,7 +830,7 @@ export const kAriaSelectedRoles = ['gridcell', 'option', 'row', 'tab', 'rowheade
830830
export function getAriaSelected(element: Element): boolean {
831831
// https://www.w3.org/TR/wai-aria-1.2/#aria-selected
832832
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
833-
if (element.tagName === 'OPTION')
833+
if (elementSafeTagName(element) === 'OPTION')
834834
return (element as HTMLOptionElement).selected;
835835
if (kAriaSelectedRoles.includes(getAriaRole(element) || ''))
836836
return getAriaBoolean(element.getAttribute('aria-selected')) === true;
@@ -843,11 +843,12 @@ export function getAriaChecked(element: Element): boolean | 'mixed' {
843843
return result === 'error' ? false : result;
844844
}
845845
export function getChecked(element: Element, allowMixed: boolean): boolean | 'mixed' | 'error' {
846+
const tagName = elementSafeTagName(element);
846847
// https://www.w3.org/TR/wai-aria-1.2/#aria-checked
847848
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
848-
if (allowMixed && element.tagName === 'INPUT' && (element as HTMLInputElement).indeterminate)
849+
if (allowMixed && tagName === 'INPUT' && (element as HTMLInputElement).indeterminate)
849850
return 'mixed';
850-
if (element.tagName === 'INPUT' && ['checkbox', 'radio'].includes((element as HTMLInputElement).type))
851+
if (tagName === 'INPUT' && ['checkbox', 'radio'].includes((element as HTMLInputElement).type))
851852
return (element as HTMLInputElement).checked;
852853
if (kAriaCheckedRoles.includes(getAriaRole(element) || '')) {
853854
const checked = element.getAttribute('aria-checked');
@@ -877,7 +878,7 @@ export const kAriaExpandedRoles = ['application', 'button', 'checkbox', 'combobo
877878
export function getAriaExpanded(element: Element): boolean | 'none' {
878879
// https://www.w3.org/TR/wai-aria-1.2/#aria-expanded
879880
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
880-
if (element.tagName === 'DETAILS')
881+
if (elementSafeTagName(element) === 'DETAILS')
881882
return (element as HTMLDetailsElement).open;
882883
if (kAriaExpandedRoles.includes(getAriaRole(element) || '')) {
883884
const expanded = element.getAttribute('aria-expanded');
@@ -894,7 +895,7 @@ export const kAriaLevelRoles = ['heading', 'listitem', 'row', 'treeitem'];
894895
export function getAriaLevel(element: Element): number {
895896
// https://www.w3.org/TR/wai-aria-1.2/#aria-level
896897
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
897-
const native = { 'H1': 1, 'H2': 2, 'H3': 3, 'H4': 4, 'H5': 5, 'H6': 6 }[element.tagName];
898+
const native = { 'H1': 1, 'H2': 2, 'H3': 3, 'H4': 4, 'H5': 5, 'H6': 6 }[elementSafeTagName(element)];
898899
if (native)
899900
return native;
900901
if (kAriaLevelRoles.includes(getAriaRole(element) || '')) {
@@ -922,7 +923,7 @@ function isNativelyDisabled(element: Element) {
922923
function belongsToDisabledFieldSet(element: Element | null): boolean {
923924
if (!element)
924925
return false;
925-
if (element.tagName === 'FIELDSET' && element.hasAttribute('disabled'))
926+
if (elementSafeTagName(element) === 'FIELDSET' && element.hasAttribute('disabled'))
926927
return true;
927928
// fieldset does not work across shadow boundaries.
928929
return belongsToDisabledFieldSet(element.parentElement);

tests/library/role-utils.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,18 @@ test('svg role=presentation', async ({ page }) => {
450450
expect.soft(await getNameAndRole(page, 'svg')).toEqual({ role: 'presentation', name: '' });
451451
});
452452

453+
test('should work with form and tricky input names', async ({ page }) => {
454+
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30616' });
455+
456+
await page.setContent(`
457+
<form aria-label="my form">
458+
<input name="tagName" value="hello" title="tagName input">
459+
<input name="localName" value="hello" title="localName input">
460+
</form>
461+
`);
462+
expect.soft(await getNameAndRole(page, 'form')).toEqual({ role: 'form', name: 'my form' });
463+
});
464+
453465
function toArray(x: any): any[] {
454466
return Array.isArray(x) ? x : [x];
455467
}

0 commit comments

Comments
 (0)