fix(role): `<input type=file>` should be a button (#35514)
This commit is contained in:
parent
6ff0c666fb
commit
4c85672f02
|
@ -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];
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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')}]
|
||||
);
|
||||
})();
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 }) => {
|
||||
|
|
|
@ -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 }) => {
|
||||
|
|
|
@ -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]
|
||||
`);
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in New Issue