diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 26b4b475d2..2941362a71 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -1431,6 +1431,16 @@ checked = page.get_by_role("checkbox").is_checked() var isChecked = await page.GetByRole(AriaRole.Checkbox).IsCheckedAsync(); ``` +### option: Locator.isChecked.checked +* since: v1.50 +* langs: js, python +- `checked` <[boolean]|"mixed"> + +### option: Locator.isChecked.checked +* since: v1.50 +* langs: java, csharp +- `checked` <[boolean]> + ### option: Locator.isChecked.timeout = %%-input-timeout-%% * since: v1.14 diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index c2adf3afc5..f1b92915ad 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -539,6 +539,12 @@ await Expect(locator).ToBeCheckedAsync(); ### option: LocatorAssertions.toBeChecked.checked * since: v1.18 +* langs: js, python +- `checked` <[boolean]|"mixed"> + +### option: LocatorAssertions.toBeChecked.checked +* since: v1.18 +* langs: java, csharp - `checked` <[boolean]> ### option: LocatorAssertions.toBeChecked.timeout = %%-js-assertions-timeout-%% diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 84e68e1b20..7a8ce15604 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -256,8 +256,9 @@ export class Locator implements api.Locator { return await this._frame.inputValue(this._selector, { strict: true, ...options }); } - async isChecked(options?: TimeoutOptions): Promise { - return await this._frame.isChecked(this._selector, { strict: true, ...options }); + async isChecked(options?: { checked?: boolean | 'mixed' } & TimeoutOptions): Promise { + const checked = options?.checked === true ? 'checked' : options?.checked === false ? 'unchecked' : options?.checked === 'mixed' ? 'mixed' : undefined; + return await this._frame.isChecked(this._selector, { strict: true, ...options, checked }); } async isDisabled(options?: TimeoutOptions): Promise { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 9b14551fb8..868e39d08b 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1591,6 +1591,7 @@ scheme.FrameInputValueResult = tObject({ }); scheme.FrameIsCheckedParams = tObject({ selector: tString, + checked: tOptional(tEnum(['checked', 'unchecked', 'mixed'])), strict: tOptional(tBoolean), timeout: tOptional(tNumber), }); diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 962f385c90..8fc4a4847f 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -777,7 +777,7 @@ export class ElementHandle extends js.JSHandle { async _setChecked(progress: Progress, state: boolean, options: { position?: types.Point } & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { const isChecked = async () => { - const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'checked'), {}); + const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'lax-checked'), {}); if (result === 'error:notconnected' || result.received === 'error:notconnected') throwElementIsNotAttached(); return result.matches; diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 1d2098b92c..0ceb9db60e 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1336,19 +1336,19 @@ export class Frame extends SdkObject { } async isDisabled(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}, scope?: dom.ElementHandle): Promise { - return this._elementState(metadata, selector, 'disabled', options, scope); + return await this._elementState(metadata, selector, 'disabled', options, scope); } async isEnabled(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}, scope?: dom.ElementHandle): Promise { - return this._elementState(metadata, selector, 'enabled', options, scope); + return await this._elementState(metadata, selector, 'enabled', options, scope); } async isEditable(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}, scope?: dom.ElementHandle): Promise { - return this._elementState(metadata, selector, 'editable', options, scope); + return await this._elementState(metadata, selector, 'editable', options, scope); } - async isChecked(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}, scope?: dom.ElementHandle): Promise { - return this._elementState(metadata, selector, 'checked', options, scope); + async isChecked(metadata: CallMetadata, selector: string, options: { checked?: 'checked' | 'unchecked' | 'mixed' } & types.QueryOnSelectorOptions = {}, scope?: dom.ElementHandle): Promise { + return await this._elementState(metadata, selector, options?.checked || 'lax-checked', options, scope); } async hover(metadata: CallMetadata, selector: string, options: types.PointerActionOptions & types.PointerActionWaitOptions = {}) { diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 0acc9bc76c..489dda6e5e 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -29,7 +29,7 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser'; import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator'; import type * as channels from '@protocol/channels'; import { Highlight } from './highlight'; -import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, getReadonly, getElementAccessibleErrorMessage } from './roleUtils'; +import { getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, getReadonly, getElementAccessibleErrorMessage, getCheckedAllowMixed, getCheckedWithoutMixed } from './roleUtils'; import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils'; import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators'; @@ -41,7 +41,7 @@ import { parseYamlTemplate } from '@isomorphic/ariaSnapshot'; export type FrameExpectParams = Omit & { expectedValue?: any }; -export type ElementState = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked' | 'unchecked' | 'mixed' | 'stable'; +export type ElementState = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked' | 'unchecked' | 'mixed' | 'lax-checked' | 'stable'; export type ElementStateWithoutStable = Exclude; export type ElementStateQueryResult = { matches: boolean, received?: string | 'error:notconnected' }; @@ -646,7 +646,7 @@ export class InjectedScript { if (state === 'checked' || state === 'unchecked' || state === 'mixed') { const need = state === 'checked' ? true : state === 'unchecked' ? false : 'mixed'; - const checked = getChecked(element, false); + const checked = getCheckedAllowMixed(element); if (checked === 'error') throw this.createStacklessError('Not a checkbox or radio button'); return { @@ -654,6 +654,17 @@ export class InjectedScript { received: checked === true ? 'checked' : checked === false ? 'unchecked' : 'mixed', }; } + + if (state === 'lax-checked') { + const checked = getCheckedWithoutMixed(element); + if (checked === 'error') + throw this.createStacklessError('Not a checkbox or radio button'); + return { + matches: checked, + received: checked ? 'checked' : 'unchecked', + }; + } + throw this.createStacklessError(`Unexpected element state "${state}"`); } @@ -1241,9 +1252,7 @@ export class InjectedScript { received: hasAttribute ? 'attribute present' : 'attribute not present', }; } else if (expression === 'to.be.checked') { - result = this.elementState(element, 'checked'); - } else if (expression === 'to.be.unchecked') { - result = this.elementState(element, 'unchecked'); + result = this.elementState(element, options.expectedValue); } else if (expression === 'to.be.disabled') { result = this.elementState(element, 'disabled'); } else if (expression === 'to.be.editable') { diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index f74c893c1b..5ed6fd7b85 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -894,7 +894,17 @@ export function getAriaChecked(element: Element): boolean | 'mixed' { const result = getChecked(element, true); return result === 'error' ? false : result; } -export function getChecked(element: Element, allowMixed: boolean): boolean | 'mixed' | 'error' { + +export function getCheckedAllowMixed(element: Element): boolean | 'mixed' | 'error' { + return getChecked(element, true); +} + +export function getCheckedWithoutMixed(element: Element): boolean | 'error' { + const result = getChecked(element, false); + return result as boolean | 'error'; +} + +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 diff --git a/packages/playwright-core/src/server/recorder/recorderRunner.ts b/packages/playwright-core/src/server/recorder/recorderRunner.ts index b0f476ffb1..ad4f11d7be 100644 --- a/packages/playwright-core/src/server/recorder/recorderRunner.ts +++ b/packages/playwright-core/src/server/recorder/recorderRunner.ts @@ -87,6 +87,7 @@ export async function performAction(callMetadata: CallMetadata, pageAliases: Map await mainFrame.expect(callMetadata, selector, { selector, expression: 'to.be.checked', + expectedValue: 'checked', isNot: !action.checked, timeout: kActionTimeout, }); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 2605af04c4..bf182aa1ef 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -13643,6 +13643,8 @@ export interface Locator { * @param options */ isChecked(options?: { + checked?: boolean|"mixed"; + /** * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` * option in the config, or by using the diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 1e3aef132a..472193d00b 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -51,13 +51,13 @@ export function toBeAttached( export function toBeChecked( this: ExpectMatcherState, locator: LocatorEx, - options?: { checked?: boolean, timeout?: number }, + options?: { checked?: boolean | 'mixed', timeout?: number }, ) { - const checked = !options || options.checked === undefined || options.checked; - const expected = checked ? 'checked' : 'unchecked'; - const arg = checked ? '' : '{ checked: false }'; + const expected = options?.checked === true ? 'checked' : options?.checked === false ? 'unchecked' : options?.checked === 'mixed' ? 'mixed' : 'checked'; + const expectedValue = options?.checked === true ? 'checked' : options?.checked === false ? 'unchecked' : options?.checked === 'mixed' ? 'mixed' : 'lax-checked'; + const arg = options?.checked === undefined ? '' : `{ checked: ${JSON.stringify(options.checked)} }`; return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', expected, arg, async (isNot, timeout) => { - return await locator._expect(checked ? 'to.be.checked' : 'to.be.unchecked', { isNot, timeout }); + return await locator._expect('to.be.checked', { isNot, timeout, expectedValue }); }, options); } diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index cff8c8ca60..3ecc1de685 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -7813,7 +7813,7 @@ interface LocatorAssertions { * @param options */ toBeChecked(options?: { - checked?: boolean; + checked?: boolean|"mixed"; /** * Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 6f9e36f0c3..1b116a5d48 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -2879,10 +2879,12 @@ export type FrameInputValueResult = { }; export type FrameIsCheckedParams = { selector: string, + checked?: 'checked' | 'unchecked' | 'mixed', strict?: boolean, timeout?: number, }; export type FrameIsCheckedOptions = { + checked?: 'checked' | 'unchecked' | 'mixed', strict?: boolean, timeout?: number, }; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 9997989f77..ac245839b9 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -2128,6 +2128,12 @@ Frame: isChecked: parameters: selector: string + checked: + type: enum? + literals: + - checked + - unchecked + - mixed strict: boolean? timeout: number? returns: diff --git a/tests/page/expect-boolean.spec.ts b/tests/page/expect-boolean.spec.ts index 1890c608b9..3e21a04936 100644 --- a/tests/page/expect-boolean.spec.ts +++ b/tests/page/expect-boolean.spec.ts @@ -35,6 +35,13 @@ test.describe('toBeChecked', () => { await expect(locator).not.toBeChecked({ checked: false }); }); + test('with checked:mixed', async ({ page }) => { + await page.setContent(''); + await page.locator('input').evaluate((e: HTMLInputElement) => e.indeterminate = true); + const locator = page.locator('input'); + await expect(locator).toBeChecked({ checked: 'mixed' }); + }); + test('fail', async ({ page }) => { await page.setContent(''); const locator = page.locator('input'); @@ -69,6 +76,13 @@ test.describe('toBeChecked', () => { expect(error.message).toContain(`expect.toBeChecked with timeout 1000ms`); }); + test('fail with checked:mixed', async ({ page }) => { + await page.setContent(''); + const locator = page.locator('input'); + const error = await expect(locator).toBeChecked({ checked: 'mixed', timeout: 1000 }).catch(e => e); + expect(error.message).toContain(`expect.toBeChecked with timeout 1000ms`); + }); + test('fail missing', async ({ page }) => { await page.setContent('
no inputs here
'); const locator2 = page.locator('input2'); diff --git a/tests/page/expect-matcher-result.spec.ts b/tests/page/expect-matcher-result.spec.ts index 2059e8401b..00e737be29 100644 --- a/tests/page/expect-matcher-result.spec.ts +++ b/tests/page/expect-matcher-result.spec.ts @@ -161,7 +161,7 @@ Call log`); } }); -test('toBeChecked({ checked: false }) should have expected: false', async ({ page }) => { +test('toBeChecked({ checked }) should have expected', async ({ page }) => { await page.setContent(` @@ -251,6 +251,28 @@ Call log`); Locator: locator('#unchecked') Expected: not unchecked Received: unchecked +Call log`); + + } + + { + const e = await expect(page.locator('#unchecked')).toBeChecked({ checked: 'mixed', timeout: 1 }).catch(e => e); + e.matcherResult.message = stripAnsi(e.matcherResult.message); + expect.soft(e.matcherResult).toEqual({ + actual: 'unchecked', + expected: 'mixed', + message: expect.stringContaining(`Timed out 1ms waiting for expect(locator).toBeChecked({ checked: "mixed" })`), + name: 'toBeChecked', + pass: false, + log: expect.any(Array), + timeout: 1, + }); + + expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toBeChecked({ checked: "mixed" }) + +Locator: locator('#unchecked') +Expected: mixed +Received: unchecked Call log`); } diff --git a/tests/page/locator-convenience.spec.ts b/tests/page/locator-convenience.spec.ts index d63b890fd9..e711c0735e 100644 --- a/tests/page/locator-convenience.spec.ts +++ b/tests/page/locator-convenience.spec.ts @@ -172,6 +172,32 @@ it('isChecked should work for indeterminate input', async ({ page }) => { await expect(page.locator('input')).not.toBeChecked(); }); +it('isChecked with explicit checked should work for indeterminate input', async ({ page }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/20190' }); + + await page.setContent(``); + await page.locator('input').evaluate((e: HTMLInputElement) => e.indeterminate = true); + + expect(await page.locator('input').isChecked({ checked: true })).toBe(false); + expect(await page.locator('input').isChecked({ checked: false })).toBe(false); + expect(await page.locator('input').isChecked({ checked: 'mixed' })).toBe(true); + await expect(page.locator('input')).toBeChecked({ checked: 'mixed' }); + + await page.locator('input').uncheck(); + + expect(await page.locator('input').isChecked({ checked: true })).toBe(false); + expect(await page.locator('input').isChecked({ checked: false })).toBe(true); + expect(await page.locator('input').isChecked({ checked: 'mixed' })).toBe(false); + await expect(page.locator('input')).toBeChecked({ checked: false }); + + await page.locator('input').check(); + + expect(await page.locator('input').isChecked({ checked: true })).toBe(true); + expect(await page.locator('input').isChecked({ checked: false })).toBe(false); + expect(await page.locator('input').isChecked({ checked: 'mixed' })).toBe(false); + await expect(page.locator('input')).toBeChecked({ checked: true }); +}); + it('allTextContents should work', async ({ page }) => { await page.setContent(`
A
B
C
`); expect(await page.locator('div').allTextContents()).toEqual(['A', 'B', 'C']);