chore: do not select a disabled option (#36418)

This commit is contained in:
Dmitry Gozman 2025-06-24 19:53:57 +01:00 committed by GitHub
parent 1c3488aa5f
commit 86c2ee6f3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 98 additions and 5 deletions

View File

@ -748,7 +748,7 @@ export class InjectedScript {
throw this.createStacklessError(`Unexpected element state "${state}"`);
}
selectOptions(node: Node, optionsToSelect: (Node | { valueOrLabel?: string, value?: string, label?: string, index?: number })[]): string[] | 'error:notconnected' | 'error:optionsnotfound' {
selectOptions(node: Node, optionsToSelect: (Node | { valueOrLabel?: string, value?: string, label?: string, index?: number })[]): string[] | 'error:notconnected' | 'error:optionsnotfound' | 'error:optionnotenabled' {
const element = this.retarget(node, 'follow-label');
if (!element)
return 'error:notconnected';
@ -776,6 +776,8 @@ export class InjectedScript {
};
if (!remainingOptionsToSelect.some(filter))
continue;
if (!this.elementState(option, 'enabled').matches)
return 'error:optionnotenabled';
selectedOptions.push(option);
if (select.multiple) {
remainingOptionsToSelect = remainingOptionsToSelect.filter(o => !filter(o));

View File

@ -1060,8 +1060,12 @@ export function getAriaDisabled(element: Element): boolean {
function isNativelyDisabled(element: Element) {
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
const isNativeFormControl = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'OPTGROUP'].includes(element.tagName);
return isNativeFormControl && (element.hasAttribute('disabled') || belongsToDisabledFieldSet(element));
const isNativeFormControl = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'OPTGROUP'].includes(elementSafeTagName(element));
return isNativeFormControl && (element.hasAttribute('disabled') || belongsToDisabledOptGroup(element) || belongsToDisabledFieldSet(element));
}
function belongsToDisabledOptGroup(element: Element): boolean {
return elementSafeTagName(element) === 'OPTION' && !!element.closest('OPTGROUP[DISABLED]');
}
function belongsToDisabledFieldSet(element: Element): boolean {

View File

@ -38,7 +38,7 @@ export type InputFilesItems = {
};
type ActionName = 'click' | 'hover' | 'dblclick' | 'tap' | 'move and up' | 'move and down';
type PerformActionResult = 'error:notvisible' | 'error:notconnected' | 'error:notinviewport' | 'error:optionsnotfound' | { missingState: ElementState } | { hitTargetDescription: string } | 'done';
type PerformActionResult = 'error:notvisible' | 'error:notconnected' | 'error:notinviewport' | 'error:optionsnotfound' | 'error:optionnotenabled' | { missingState: ElementState } | { hitTargetDescription: string } | 'done';
export class NonRecoverableDOMError extends Error {
}
@ -336,6 +336,10 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
progress.log(' did not find some options');
continue;
}
if (result === 'error:optionnotenabled') {
progress.log(' option being selected is not enabled');
continue;
}
if (typeof result === 'object' && 'hitTargetDescription' in result) {
progress.log(` ${result.hitTargetDescription} intercepts pointer events`);
continue;

View File

@ -320,7 +320,7 @@ it('input event.composed should be true and cross shadow dom boundary', async ({
expect(await page.evaluate(() => window['firedBodyEvents'])).toEqual(['input:true']);
});
it('should wait for option to be enabled', async ({ page }) => {
it('should wait for select to be enabled', async ({ page }) => {
await page.setContent(`
<select disabled>
<option>one</option>
@ -346,6 +346,68 @@ it('should wait for option to be enabled', async ({ page }) => {
await expect(page.locator('select')).toHaveValue('two');
});
it('should wait for option to be enabled', async ({ page }) => {
await page.setContent(`
<select>
<option>one</option>
<option disabled id=myoption>two</option>
</select>
<script>
function hydrate() {
const option = document.querySelector('#myoption');
option.removeAttribute('disabled');
const select = document.querySelector('select');
select.addEventListener('change', () => {
window['result'] = select.value;
});
}
</script>
`);
const error = await page.locator('select').selectOption('two', { timeout: 1000 }).catch(e => e);
expect(error.message).toContain('option being selected is not enabled');
const selectPromise = page.locator('select').selectOption('two');
await new Promise(f => setTimeout(f, 1000));
await page.evaluate(() => (window as any).hydrate());
await selectPromise;
expect(await page.evaluate(() => window['result'])).toEqual('two');
await expect(page.locator('select')).toHaveValue('two');
});
it('should wait for optgroup to be enabled', async ({ page }) => {
await page.setContent(`
<select>
<option>one</option>
<optgroup label="Group" disabled id=mygroup>
<option>two</option>
</optgroup>
</select>
<script>
function hydrate() {
const group = document.querySelector('#mygroup');
group.removeAttribute('disabled');
const select = document.querySelector('select');
select.addEventListener('change', () => {
window['result'] = select.value;
});
}
</script>
`);
const error = await page.locator('select').selectOption('two', { timeout: 1000 }).catch(e => e);
expect(error.message).toContain('option being selected is not enabled');
const selectPromise = page.locator('select').selectOption('two');
await new Promise(f => setTimeout(f, 1000));
await page.evaluate(() => (window as any).hydrate());
await selectPromise;
expect(await page.evaluate(() => window['result'])).toEqual('two');
await expect(page.locator('select')).toHaveValue('two');
});
it('should wait for select to be swapped', async ({ page }) => {
await page.setContent(`
<select disabled>

View File

@ -217,6 +217,15 @@ test('should support disabled', async ({ page }) => {
<fieldset disabled>
<button>Yay</button>
</fieldset>
<select>
<optgroup disabled>
<option>one</option>
</optgroup>
<optgroup>
<option>two</option>
</optgroup>
<option disabled>three</option>
</select>
`);
expect(await page.locator(`role=button[disabled]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([
`<button disabled="">Bye</button>`,
@ -241,6 +250,18 @@ test('should support disabled', async ({ page }) => {
`<button>Hi</button>`,
`<button aria-disabled="false">Oh</button>`,
]);
expect(await page.getByRole('option', { disabled: true }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([
`<option>one</option>`,
`<option disabled="">three</option>`,
]);
expect(await page.getByRole('option', { disabled: false }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([
`<option>two</option>`,
]);
expect(await page.getByRole('option').evaluateAll(els => els.map(e => e.outerHTML))).toEqual([
`<option>one</option>`,
`<option>two</option>`,
`<option disabled="">three</option>`,
]);
});
test('should inherit disabled from the ancestor', async ({ page }) => {