Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(role): extract tagName safely #30636

Merged
merged 1 commit into from
May 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/playwright-core/src/server/injected/domUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,12 @@ export function isVisibleTextNode(node: Text) {
const rect = range.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}

export function elementSafeTagName(element: Element) {
// Named inputs, e.g. <input name=tagName>, will be exposed as fields on the parent <form>
// and override its properties.
if (element instanceof HTMLFormElement)
return 'FORM';
// Elements from the svg namespace do not have uppercase tagName right away.
return element.tagName.toUpperCase();
}
73 changes: 37 additions & 36 deletions packages/playwright-core/src/server/injected/roleUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

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

function hasExplicitAccessibleName(e: Element) {
return e.hasAttribute('aria-label') || e.hasAttribute('aria-labelledby');
Expand Down Expand Up @@ -70,7 +70,7 @@ function isFocusable(element: Element) {
}

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

function getImplicitAriaRole(element: Element): string | null {
// Elements from the svg namespace do not have uppercase tagName.
const implicitRole = kImplicitRoleByTagName[element.tagName.toUpperCase()]?.(element) || '';
const implicitRole = kImplicitRoleByTagName[elementSafeTagName(element)]?.(element) || '';
if (!implicitRole)
return null;
// Inherit presentation role when required.
// https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none
let ancestor: Element | null = element;
while (ancestor) {
const parent = parentElementOrShadowHost(ancestor);
const parents = kPresentationInheritanceParents[ancestor.tagName];
if (!parents || !parent || !parents.includes(parent.tagName))
const parents = kPresentationInheritanceParents[elementSafeTagName(ancestor)];
if (!parents || !parent || !parents.includes(elementSafeTagName(parent)))
break;
const parentExplicitRole = getExplicitAriaRole(parent);
if ((parentExplicitRole === 'none' || parentExplicitRole === 'presentation') && !hasPresentationConflictResolution(parent, parentExplicitRole))
Expand Down Expand Up @@ -267,7 +266,7 @@ function getAriaBoolean(attr: string | null) {
// `Any descendants of elements that have the characteristic "Children Presentational: True"`
// https://www.w3.org/TR/wai-aria-1.2/#aria-hidden
export function isElementHiddenForAria(element: Element): boolean {
if (['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(element.tagName))
if (['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(elementSafeTagName(element)))
return true;
const style = getElementComputedStyle(element);
const isSlot = element.nodeName === 'SLOT';
Expand Down Expand Up @@ -527,6 +526,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
}

const role = getAriaRole(element) || '';
const tagName = elementSafeTagName(element);

// step 2c:
// if the current node is a control embedded within the label (e.g. any element directly referenced by aria-labelledby) for another widget...
Expand All @@ -542,22 +542,22 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
if (!isOwnLabel && !isOwnLabelledBy) {
if (role === 'textbox') {
options.visitedElements.add(element);
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA')
if (tagName === 'INPUT' || tagName === 'TEXTAREA')
return (element as HTMLInputElement | HTMLTextAreaElement).value;
return element.textContent || '';
}
if (['combobox', 'listbox'].includes(role)) {
options.visitedElements.add(element);
let selectedOptions: Element[];
if (element.tagName === 'SELECT') {
if (tagName === 'SELECT') {
selectedOptions = [...(element as HTMLSelectElement).selectedOptions];
if (!selectedOptions.length && (element as HTMLSelectElement).options.length)
selectedOptions.push((element as HTMLSelectElement).options[0]);
} else {
const listbox = role === 'combobox' ? queryInAriaOwned(element, '*').find(e => getAriaRole(e) === 'listbox') : element;
selectedOptions = listbox ? queryInAriaOwned(listbox, '[aria-selected="true"]').filter(e => getAriaRole(e) === 'option') : [];
}
if (!selectedOptions.length && element.tagName === 'INPUT') {
if (!selectedOptions.length && tagName === 'INPUT') {
// SPEC DIFFERENCE:
// This fallback is not explicitly mentioned in the spec, but all browsers and
// wpt test name_heading-combobox-focusable-alternative-manual.html do this.
Expand Down Expand Up @@ -596,7 +596,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
// Spec says to ignore this when aria-labelledby is defined.
// WebKit follows the spec, while Chromium and Firefox do not.
// We align with Chromium and Firefox here.
if (element.tagName === 'INPUT' && ['button', 'submit', 'reset'].includes((element as HTMLInputElement).type)) {
if (tagName === 'INPUT' && ['button', 'submit', 'reset'].includes((element as HTMLInputElement).type)) {
options.visitedElements.add(element);
const value = (element as HTMLInputElement).value || '';
if (trimFlatString(value))
Expand All @@ -613,7 +613,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
//
// SPEC DIFFERENCE.
// Spec says to ignore this when aria-labelledby is defined, but all browsers take it into account.
if (element.tagName === 'INPUT' && (element as HTMLInputElement).type === 'image') {
if (tagName === 'INPUT' && (element as HTMLInputElement).type === 'image') {
options.visitedElements.add(element);
const labels = (element as HTMLInputElement).labels || [];
if (labels.length && !options.embeddedInLabelledBy)
Expand All @@ -630,7 +630,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
}

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

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

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

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

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

// https://w3c.github.io/html-aam/#table-element
if (element.tagName === 'TABLE') {
if (tagName === 'TABLE') {
options.visitedElements.add(element);
for (let child = element.firstElementChild; child; child = child.nextElementSibling) {
if (child.tagName === 'CAPTION') {
if (elementSafeTagName(child) === 'CAPTION') {
return getTextAlternativeInternal(child, {
...childOptions,
embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) },
Expand All @@ -731,7 +731,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
}

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

// https://www.w3.org/TR/svg-aam-1.0/#mapping_additional_nd
if (element.tagName.toUpperCase() === 'SVG' || (element as SVGElement).ownerSVGElement) {
if (tagName === 'SVG' || (element as SVGElement).ownerSVGElement) {
options.visitedElements.add(element);
for (let child = element.firstElementChild; child; child = child.nextElementSibling) {
if (child.tagName.toUpperCase() === 'TITLE' && (child as SVGElement).ownerSVGElement) {
if (elementSafeTagName(child) === 'TITLE' && (child as SVGElement).ownerSVGElement) {
return getTextAlternativeInternal(child, {
...childOptions,
embeddedInLabelledBy: { element: child, hidden: isElementHiddenForAria(child) },
});
}
}
}
if ((element as SVGElement).ownerSVGElement && element.tagName.toUpperCase() === 'A') {
if ((element as SVGElement).ownerSVGElement && tagName === 'A') {
const title = element.getAttribute('xlink:title') || '';
if (trimFlatString(title)) {
options.visitedElements.add(element);
Expand All @@ -762,7 +762,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
}

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

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

// step 2i.
if (!['presentation', 'none'].includes(role) || element.tagName === 'IFRAME') {
if (!['presentation', 'none'].includes(role) || tagName === 'IFRAME') {
options.visitedElements.add(element);
const title = element.getAttribute('title') || '';
if (trimFlatString(title))
Expand All @@ -830,7 +830,7 @@ export const kAriaSelectedRoles = ['gridcell', 'option', 'row', 'tab', 'rowheade
export function getAriaSelected(element: Element): boolean {
// https://www.w3.org/TR/wai-aria-1.2/#aria-selected
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
if (element.tagName === 'OPTION')
if (elementSafeTagName(element) === 'OPTION')
return (element as HTMLOptionElement).selected;
if (kAriaSelectedRoles.includes(getAriaRole(element) || ''))
return getAriaBoolean(element.getAttribute('aria-selected')) === true;
Expand All @@ -843,11 +843,12 @@ export function getAriaChecked(element: Element): boolean | 'mixed' {
return result === 'error' ? false : result;
}
export function getChecked(element: Element, allowMixed: boolean): boolean | 'mixed' | 'error' {
const tagName = elementSafeTagName(element);
// https://www.w3.org/TR/wai-aria-1.2/#aria-checked
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
if (allowMixed && element.tagName === 'INPUT' && (element as HTMLInputElement).indeterminate)
if (allowMixed && tagName === 'INPUT' && (element as HTMLInputElement).indeterminate)
return 'mixed';
if (element.tagName === 'INPUT' && ['checkbox', 'radio'].includes((element as HTMLInputElement).type))
if (tagName === 'INPUT' && ['checkbox', 'radio'].includes((element as HTMLInputElement).type))
return (element as HTMLInputElement).checked;
if (kAriaCheckedRoles.includes(getAriaRole(element) || '')) {
const checked = element.getAttribute('aria-checked');
Expand Down Expand Up @@ -877,7 +878,7 @@ export const kAriaExpandedRoles = ['application', 'button', 'checkbox', 'combobo
export function getAriaExpanded(element: Element): boolean | 'none' {
// https://www.w3.org/TR/wai-aria-1.2/#aria-expanded
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
if (element.tagName === 'DETAILS')
if (elementSafeTagName(element) === 'DETAILS')
return (element as HTMLDetailsElement).open;
if (kAriaExpandedRoles.includes(getAriaRole(element) || '')) {
const expanded = element.getAttribute('aria-expanded');
Expand All @@ -894,7 +895,7 @@ export const kAriaLevelRoles = ['heading', 'listitem', 'row', 'treeitem'];
export function getAriaLevel(element: Element): number {
// https://www.w3.org/TR/wai-aria-1.2/#aria-level
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
const native = { 'H1': 1, 'H2': 2, 'H3': 3, 'H4': 4, 'H5': 5, 'H6': 6 }[element.tagName];
const native = { 'H1': 1, 'H2': 2, 'H3': 3, 'H4': 4, 'H5': 5, 'H6': 6 }[elementSafeTagName(element)];
if (native)
return native;
if (kAriaLevelRoles.includes(getAriaRole(element) || '')) {
Expand Down Expand Up @@ -922,7 +923,7 @@ function isNativelyDisabled(element: Element) {
function belongsToDisabledFieldSet(element: Element | null): boolean {
if (!element)
return false;
if (element.tagName === 'FIELDSET' && element.hasAttribute('disabled'))
if (elementSafeTagName(element) === 'FIELDSET' && element.hasAttribute('disabled'))
return true;
// fieldset does not work across shadow boundaries.
return belongsToDisabledFieldSet(element.parentElement);
Expand Down
12 changes: 12 additions & 0 deletions tests/library/role-utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,18 @@ test('svg role=presentation', async ({ page }) => {
expect.soft(await getNameAndRole(page, 'svg')).toEqual({ role: 'presentation', name: '' });
});

test('should work with form and tricky input names', async ({ page }) => {
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30616' });

await page.setContent(`
<form aria-label="my form">
<input name="tagName" value="hello" title="tagName input">
<input name="localName" value="hello" title="localName input">
</form>
`);
expect.soft(await getNameAndRole(page, 'form')).toEqual({ role: 'form', name: 'my form' });
});

function toArray(x: any): any[] {
return Array.isArray(x) ? x : [x];
}
Loading