fix(role): `<input type=file>` should be a button (#35514)

This commit is contained in:
Dmitry Gozman 2025-04-08 08:02:19 +00:00 committed by GitHub
parent 6ff0c666fb
commit 4c85672f02
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 67 additions and 28 deletions

View File

@ -16,7 +16,7 @@
import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils';
import { getElementComputedStyle } from './domUtils';
import { getElementComputedStyle, getGlobalOptions } from './domUtils';
import * as roleUtils from './roleUtils';
import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml';
@ -174,7 +174,7 @@ function toAriaNode(builtins: Builtins, element: Element, includeIframe: boolean
result.selected = roleUtils.getAriaSelected(element);
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
if (element.type !== 'checkbox' && element.type !== 'radio')
if (element.type !== 'checkbox' && element.type !== 'radio' && (element.type !== 'file' || getGlobalOptions().inputFileRoleTextbox))
result.children = [element.value];
}

View File

@ -14,9 +14,16 @@
* limitations under the License.
*/
let browserNameForWorkarounds = '';
export function setBrowserName(name: string) {
browserNameForWorkarounds = name;
type GlobalOptions = {
browserNameForWorkarounds?: string;
inputFileRoleTextbox?: boolean;
};
let globalOptions: GlobalOptions = {};
export function setGlobalOptions(options: GlobalOptions) {
globalOptions = options;
}
export function getGlobalOptions(): GlobalOptions {
return globalOptions;
}
export function isInsideScope(scope: Node, element: Element | undefined): boolean {
@ -83,7 +90,7 @@ export function isElementStyleVisibilityVisible(element: Element, style?: CSSSty
// All the browser implement it, but WebKit has a bug which prevents us from using it:
// https://bugs.webkit.org/show_bug.cgi?id=264733
// @ts-ignore
if (Element.prototype.checkVisibility && browserNameForWorkarounds !== 'webkit') {
if (Element.prototype.checkVisibility && globalOptions.browserNameForWorkarounds !== 'webkit') {
if (!element.checkVisibility())
return false;
} else {

View File

@ -21,7 +21,7 @@ import { parseAttributeSelector, parseSelector, stringifySelector, visitAllSelec
import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis } from '@isomorphic/stringUtils';
import { generateAriaTree, getAllByAria, matchesAriaTree, renderAriaTree } from './ariaSnapshot';
import { enclosingShadowRootOrDocument, isElementVisible, isInsideScope, parentElementOrShadowHost, setBrowserName } from './domUtils';
import { enclosingShadowRootOrDocument, isElementVisible, isInsideScope, parentElementOrShadowHost, setGlobalOptions } from './domUtils';
import { Highlight } from './highlight';
import { kLayoutSelectorNames, layoutSelectorScore } from './layoutSelectorUtils';
import { createReactEngine } from './reactSelectorEngine';
@ -107,7 +107,7 @@ export class InjectedScript {
private _allHitTargetInterceptorEvents: Builtins.Set<string>;
// eslint-disable-next-line no-restricted-globals
constructor(window: Window & typeof globalThis, isUnderTest: boolean, sdkLanguage: Language, testIdAttributeNameForStrictErrorAndConsoleCodegen: string, stableRafCount: number, browserName: string, customEngines: { name: string, engine: SelectorEngine }[]) {
constructor(window: Window & typeof globalThis, isUnderTest: boolean, sdkLanguage: Language, testIdAttributeNameForStrictErrorAndConsoleCodegen: string, stableRafCount: number, browserName: string, inputFileRoleTextbox: boolean, customEngines: { name: string, engine: SelectorEngine }[]) {
this.window = window;
this.document = window.document;
this.isUnderTest = isUnderTest;
@ -217,7 +217,7 @@ export class InjectedScript {
this._stableRafCount = stableRafCount;
this._browserName = browserName;
setBrowserName(browserName);
setGlobalOptions({ browserNameForWorkarounds: browserName, inputFileRoleTextbox });
this._setupGlobalListenersRemovalDetection();
this._setupHitTargetInterceptors();

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
import { closestCrossShadow, elementSafeTagName, enclosingShadowRootOrDocument, getElementComputedStyle, isElementStyleVisibilityVisible, isVisibleTextNode, parentElementOrShadowHost } from './domUtils';
import { getGlobalOptions, closestCrossShadow, elementSafeTagName, enclosingShadowRootOrDocument, getElementComputedStyle, isElementStyleVisibilityVisible, isVisibleTextNode, parentElementOrShadowHost } from './domUtils';
import type { AriaRole } from '@isomorphic/ariaSnapshot';
import type { Builtins } from '@isomorphic/builtins';
@ -131,6 +131,11 @@ const kImplicitRoleByTagName: { [tagName: string]: (e: Element) => AriaRole | nu
}
if (type === 'hidden')
return null;
// File inputs do not have a role by the spec: https://www.w3.org/TR/html-aam-1.0/#el-input-file.
// However, there are open issues about fixing it: https://github.com/w3c/aria/issues/1926.
// All browsers report it as a button, and it is rendered as a button, so we do "button".
if (type === 'file' && !getGlobalOptions().inputFileRoleTextbox)
return 'button';
return inputTypeToRole[type] || 'textbox';
},
'INS': () => 'insertion',
@ -665,6 +670,18 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
return title;
}
// SPEC DIFFERENCE.
// There is no spec for this, but Chromium/WebKit do "Choose File" so we follow that.
// All browsers respect labels, aria-labelledby and aria-label.
// No browsers respect the title attribute, although w3c accname tests disagree. We follow browsers.
if (!getGlobalOptions().inputFileRoleTextbox && tagName === 'INPUT' && (element as HTMLInputElement).type === 'file') {
options.visitedElements.add(element);
const labels = (element as HTMLInputElement).labels || [];
if (labels.length && !options.embeddedInLabelledBy)
return getAccessibleNameFromAssociatedLabels(labels, options);
return 'Choose File';
}
// https://w3c.github.io/html-aam/#input-type-image-accessible-name-computation
//
// SPEC DIFFERENCE.

View File

@ -101,6 +101,7 @@ export class FrameExecutionContext extends js.ExecutionContext {
${JSON.stringify(selectorsRegistry.testIdAttributeName())},
${this.frame._page._delegate.rafCountForStablePosition()},
"${this.frame._page._browserContext._browser.options.name}",
${process.env.PLAYWRIGHT_INPUT_FILE_TEXTBOX ? 'true' : 'false'},
[${custom.join(',\n')}]
);
})();

View File

@ -78,7 +78,7 @@ export const SnapshotTabsView: React.FunctionComponent<{
<ToolbarButton icon='link-external' title='Open snapshot in a new tab' disabled={!snapshotUrls?.popoutUrl} onClick={() => {
const win = window.open(snapshotUrls?.popoutUrl || '', '_blank');
win?.addEventListener('DOMContentLoaded', () => {
const injectedScript = new InjectedScript(win as any, false, sdkLanguage, testIdAttributeName, 1, 'chromium', []);
const injectedScript = new InjectedScript(win as any, false, sdkLanguage, testIdAttributeName, 1, 'chromium', false, []);
new ConsoleAPI(injectedScript);
});
}} />
@ -281,7 +281,7 @@ function createRecorders(recorders: { recorder: Recorder, frameSelector: string
return;
const win = frameWindow as any;
if (!win._recorder) {
const injectedScript = new InjectedScript(frameWindow as any, isUnderTest, sdkLanguage, testIdAttributeName, 1, 'chromium', []);
const injectedScript = new InjectedScript(frameWindow as any, isUnderTest, sdkLanguage, testIdAttributeName, 1, 'chromium', false, []);
const recorder = new Recorder(injectedScript);
win._injectedScript = injectedScript;
win._recorder = { recorder, frameSelector: parentFrameSelector };

View File

@ -119,19 +119,19 @@ await page.CloseAsync();`);
const sources = await recorder.waitForOutput('JavaScript', 'setInputFiles');
expect(sources.get('JavaScript')!.text).toContain(`
await page.getByRole('textbox').setInputFiles('file-to-upload.txt');`);
await page.getByRole('button', { name: 'Choose File' }).setInputFiles('file-to-upload.txt');`);
expect(sources.get('Java')!.text).toContain(`
page.getByRole(AriaRole.TEXTBOX).setInputFiles(Paths.get("file-to-upload.txt"));`);
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Choose File")).setInputFiles(Paths.get("file-to-upload.txt"));`);
expect(sources.get('Python')!.text).toContain(`
page.get_by_role("textbox").set_input_files(\"file-to-upload.txt\")`);
page.get_by_role("button", name="Choose File").set_input_files(\"file-to-upload.txt\")`);
expect(sources.get('Python Async')!.text).toContain(`
await page.get_by_role("textbox").set_input_files(\"file-to-upload.txt\")`);
await page.get_by_role("button", name="Choose File").set_input_files(\"file-to-upload.txt\")`);
expect(sources.get('C#')!.text).toContain(`
await page.GetByRole(AriaRole.Textbox).SetInputFilesAsync(new[] { \"file-to-upload.txt\" });`);
await page.GetByRole(AriaRole.Button, new() { Name = "Choose File" }).SetInputFilesAsync(new[] { \"file-to-upload.txt\" });`);
});
test('should upload multiple files', async ({ openRecorder, browserName, asset, isLinux }) => {
@ -149,19 +149,19 @@ await page.GetByRole(AriaRole.Textbox).SetInputFilesAsync(new[] { \"file-to-uplo
const sources = await recorder.waitForOutput('JavaScript', 'setInputFiles');
expect(sources.get('JavaScript')!.text).toContain(`
await page.getByRole('textbox').setInputFiles(['file-to-upload.txt', 'file-to-upload-2.txt']);`);
await page.getByRole('button', { name: 'Choose File' }).setInputFiles(['file-to-upload.txt', 'file-to-upload-2.txt']);`);
expect(sources.get('Java')!.text).toContain(`
page.getByRole(AriaRole.TEXTBOX).setInputFiles(new Path[] {Paths.get("file-to-upload.txt"), Paths.get("file-to-upload-2.txt")});`);
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Choose File")).setInputFiles(new Path[] {Paths.get("file-to-upload.txt"), Paths.get("file-to-upload-2.txt")});`);
expect(sources.get('Python')!.text).toContain(`
page.get_by_role("textbox").set_input_files([\"file-to-upload.txt\", \"file-to-upload-2.txt\"]`);
page.get_by_role("button", name="Choose File").set_input_files([\"file-to-upload.txt\", \"file-to-upload-2.txt\"]`);
expect(sources.get('Python Async')!.text).toContain(`
await page.get_by_role("textbox").set_input_files([\"file-to-upload.txt\", \"file-to-upload-2.txt\"]`);
await page.get_by_role("button", name="Choose File").set_input_files([\"file-to-upload.txt\", \"file-to-upload-2.txt\"]`);
expect(sources.get('C#')!.text).toContain(`
await page.GetByRole(AriaRole.Textbox).SetInputFilesAsync(new[] { \"file-to-upload.txt\", \"file-to-upload-2.txt\" });`);
await page.GetByRole(AriaRole.Button, new() { Name = "Choose File" }).SetInputFilesAsync(new[] { \"file-to-upload.txt\", \"file-to-upload-2.txt\" });`);
});
test('should clear files', async ({ openRecorder, browserName, asset, isLinux }) => {
@ -179,19 +179,19 @@ await page.GetByRole(AriaRole.Textbox).SetInputFilesAsync(new[] { \"file-to-uplo
const sources = await recorder.waitForOutput('JavaScript', 'setInputFiles');
expect(sources.get('JavaScript')!.text).toContain(`
await page.getByRole('textbox').setInputFiles([]);`);
await page.getByRole('button', { name: 'Choose File' }).setInputFiles([]);`);
expect(sources.get('Java')!.text).toContain(`
page.getByRole(AriaRole.TEXTBOX).setInputFiles(new Path[0]);`);
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Choose File")).setInputFiles(new Path[0]);`);
expect(sources.get('Python')!.text).toContain(`
page.get_by_role("textbox").set_input_files([])`);
page.get_by_role("button", name="Choose File").set_input_files([])`);
expect(sources.get('Python Async')!.text).toContain(`
await page.get_by_role("textbox").set_input_files([])`);
await page.get_by_role("button", name="Choose File").set_input_files([])`);
expect(sources.get('C#')!.text).toContain(`
await page.GetByRole(AriaRole.Textbox).SetInputFilesAsync(new[] { });`);
await page.GetByRole(AriaRole.Button, new() { Name = "Choose File" }).SetInputFilesAsync(new[] { });`);
});
test('should download files', async ({ openRecorder, server }) => {

View File

@ -41,6 +41,9 @@ for (let range = 0; range <= ranges.length; range++) {
'name_test_case_659-manual.html',
// This test expects ::before + title + ::after, which is neither 2F nor 2I.
'name_test_case_660-manual.html',
// These two tests expect <input type=file title=...> to respect the title, but browsers do not.
'name_test_case_751-manual.html',
'name_file-title-manual.html',
// Spec says role=combobox should use selected options, not a title attribute.
'description_1.0_combobox-focusable-manual.html',
];
@ -320,6 +323,9 @@ test('native controls', async ({ page }) => {
<button id="button2" role="combobox">BUTTON2</button>
<button id="button3">BUTTON3</button>
<button id="button4" title="BUTTON4"></button>
<input id="file1" type=file>
<label for="file2">FILE2</label><input id="file2" type=file>
`);
expect.soft(await getNameAndRole(page, '#text1')).toEqual({ role: 'textbox', name: 'TEXT1' });
@ -332,6 +338,8 @@ test('native controls', async ({ page }) => {
expect.soft(await getNameAndRole(page, '#button2')).toEqual({ role: 'combobox', name: '' });
expect.soft(await getNameAndRole(page, '#button3')).toEqual({ role: 'button', name: 'BUTTON3' });
expect.soft(await getNameAndRole(page, '#button4')).toEqual({ role: 'button', name: 'BUTTON4' });
expect.soft(await getNameAndRole(page, '#file1')).toEqual({ role: 'button', name: 'Choose File' });
expect.soft(await getNameAndRole(page, '#file2')).toEqual({ role: 'button', name: 'FILE2' });
});
test('native controls labelled-by', async ({ page }) => {

View File

@ -416,13 +416,19 @@ it('should ignore presentation and none roles', async ({ page }) => {
`);
});
it('should treat input value as text in templates', async ({ page }) => {
it('should treat input value as text in templates, but not for checkbox/radio/file', async ({ page }) => {
await page.setContent(`
<input value='hello world'>
<input type=file>
<input type=checkbox checked>
<input type=radio checked>
`);
await checkAndMatchSnapshot(page.locator('body'), `
- textbox: hello world
- button "Choose File"
- checkbox [checked]
- radio [checked]
`);
});