From 86c2ee6f3cf83e26972e5bb06a3d537a88b47d63 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 24 Jun 2025 19:53:57 +0100 Subject: [PATCH] chore: do not select a disabled option (#36418) --- packages/injected/src/injectedScript.ts | 4 +- packages/injected/src/roleUtils.ts | 8 ++- packages/playwright-core/src/server/dom.ts | 6 +- tests/page/page-select-option.spec.ts | 64 +++++++++++++++++++++- tests/page/selectors-role.spec.ts | 21 +++++++ 5 files changed, 98 insertions(+), 5 deletions(-) diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index bcc6be892e..e31bf7b1c4 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -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)); diff --git a/packages/injected/src/roleUtils.ts b/packages/injected/src/roleUtils.ts index 139b26bb63..75bb580dad 100644 --- a/packages/injected/src/roleUtils.ts +++ b/packages/injected/src/roleUtils.ts @@ -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 { diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index ffb61584fc..60abad7dfb 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -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 extends js.JSHandle { 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; diff --git a/tests/page/page-select-option.spec.ts b/tests/page/page-select-option.spec.ts index 160996446d..0355b4592b 100644 --- a/tests/page/page-select-option.spec.ts +++ b/tests/page/page-select-option.spec.ts @@ -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(` + + + + + + `); + + 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(` + + + + `); + + 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(` + + + + + + + + `); expect(await page.locator(`role=button[disabled]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ ``, @@ -241,6 +250,18 @@ test('should support disabled', async ({ page }) => { ``, ``, ]); + expect(await page.getByRole('option', { disabled: true }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + ``, + ``, + ]); + expect(await page.getByRole('option', { disabled: false }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + ``, + ]); + expect(await page.getByRole('option').evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + ``, + ``, + ``, + ]); }); test('should inherit disabled from the ancestor', async ({ page }) => {