From 13bdd3c92faa015f7c3f3f340650ea605ea8e1cb Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 9 Jan 2025 18:18:15 -0800 Subject: [PATCH] feat(toBeChecked): allow indeterminate expectation (#34269) --- docs/src/api/class-locatorassertions.md | 10 ++++++ .../src/server/injected/injectedScript.ts | 31 ++++++++++++++----- .../src/server/injected/roleUtils.ts | 12 ++++++- .../src/server/recorder/recorderRunner.ts | 1 + packages/playwright/src/matchers/matchers.ts | 22 ++++++++++--- packages/playwright/types/test.d.ts | 13 ++++++++ tests/page/expect-boolean.spec.ts | 22 +++++++++++++ tests/page/expect-matcher-result.spec.ts | 24 +++++++++++++- 8 files changed, 120 insertions(+), 15 deletions(-) diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index c2adf3afc5..968d6375d7 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -541,6 +541,16 @@ await Expect(locator).ToBeCheckedAsync(); * since: v1.18 - `checked` <[boolean]> +Provides state to assert for. Asserts for input to be checked by default. +This option can't be used when [`option: LocatorAssertions.toBeChecked.indeterminate`] is set to true. + +### option: LocatorAssertions.toBeChecked.indeterminate +* since: v1.50 +- `indeterminate` <[boolean]> + +Asserts that the element is in the indeterminate (mixed) state. Only supported for checkboxes and radio buttons. +This option can't be true when [`option: LocatorAssertions.toBeChecked.checked`] is provided. + ### option: LocatorAssertions.toBeChecked.timeout = %%-js-assertions-timeout-%% * since: v1.18 diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index ed963f2b3f..69ab741de6 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' | 'indeterminate' | 'stable'; export type ElementStateWithoutStable = Exclude; export type ElementStateQueryResult = { matches: boolean, received?: string | 'error:notconnected' }; @@ -644,13 +644,23 @@ export class InjectedScript { }; } - if (state === 'checked' || state === 'unchecked' || state === 'mixed') { - const need = state === 'checked' ? true : state === 'unchecked' ? false : 'mixed'; - const checked = getChecked(element, false); + if (state === 'checked' || state === 'unchecked') { + const need = state === 'checked'; + const checked = getCheckedWithoutMixed(element); if (checked === 'error') throw this.createStacklessError('Not a checkbox or radio button'); return { matches: need === checked, + received: checked ? 'checked' : 'unchecked', + }; + } + + if (state === 'indeterminate') { + const checked = getCheckedAllowMixed(element); + if (checked === 'error') + throw this.createStacklessError('Not a checkbox or radio button'); + return { + matches: checked === 'mixed', received: checked === true ? 'checked' : checked === false ? 'unchecked' : 'mixed', }; } @@ -1267,9 +1277,14 @@ 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'); + const { checked, indeterminate } = options.expectedValue; + if (indeterminate) { + if (checked !== undefined) + throw this.createStacklessError('Can\'t assert indeterminate and checked at the same time'); + result = this.elementState(element, 'indeterminate'); + } else { + result = this.elementState(element, checked === false ? 'unchecked' : 'checked'); + } } 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..1b33895f98 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: action.checked }, isNot: !action.checked, timeout: kActionTimeout, }); diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 1e3aef132a..c942fef246 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -51,13 +51,25 @@ export function toBeAttached( export function toBeChecked( this: ExpectMatcherState, locator: LocatorEx, - options?: { checked?: boolean, timeout?: number }, + options?: { checked?: boolean, indeterminate?: boolean, timeout?: number }, ) { - const checked = !options || options.checked === undefined || options.checked; - const expected = checked ? 'checked' : 'unchecked'; - const arg = checked ? '' : '{ checked: false }'; + const checked = options?.checked; + const indeterminate = options?.indeterminate; + const expectedValue = { + checked, + indeterminate, + }; + let expected: string; + let arg: string; + if (options?.indeterminate) { + expected = 'indeterminate'; + arg = `{ indeterminate: true }`; + } else { + expected = options?.checked === false ? 'unchecked' : 'checked'; + arg = options?.checked === false ? `{ checked: false }` : ''; + } 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..6c772e3afb 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -7813,8 +7813,21 @@ interface LocatorAssertions { * @param options */ toBeChecked(options?: { + /** + * Provides state to assert for. Asserts for input to be checked by default. This option can't be used when + * [`indeterminate`](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-be-checked-option-indeterminate) + * is set to true. + */ checked?: boolean; + /** + * Asserts that the element is in the indeterminate (mixed) state. Only supported for checkboxes and radio buttons. + * This option can't be true when + * [`checked`](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-be-checked-option-checked) + * is provided. + */ + indeterminate?: boolean; + /** * Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. */ diff --git a/tests/page/expect-boolean.spec.ts b/tests/page/expect-boolean.spec.ts index 1890c608b9..49b06b7747 100644 --- a/tests/page/expect-boolean.spec.ts +++ b/tests/page/expect-boolean.spec.ts @@ -35,6 +35,21 @@ test.describe('toBeChecked', () => { await expect(locator).not.toBeChecked({ checked: false }); }); + test('with indeterminate:true', async ({ page }) => { + await page.setContent(''); + await page.locator('input').evaluate((e: HTMLInputElement) => e.indeterminate = true); + const locator = page.locator('input'); + await expect(locator).toBeChecked({ indeterminate: true }); + }); + + test('with indeterminate:true and checked', async ({ page }) => { + await page.setContent(''); + await page.locator('input').evaluate((e: HTMLInputElement) => e.indeterminate = true); + const locator = page.locator('input'); + const error = await expect(locator).toBeChecked({ indeterminate: true, checked: false }).catch(e => e); + expect(error.message).toContain(`Can\'t assert indeterminate and checked at the same time`); + }); + test('fail', async ({ page }) => { await page.setContent(''); const locator = page.locator('input'); @@ -69,6 +84,13 @@ test.describe('toBeChecked', () => { expect(error.message).toContain(`expect.toBeChecked with timeout 1000ms`); }); + test('fail with indeterminate: true', async ({ page }) => { + await page.setContent(''); + const locator = page.locator('input'); + const error = await expect(locator).toBeChecked({ indeterminate: true, 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..676a29fc67 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({ indeterminate: true, timeout: 1 }).catch(e => e); + e.matcherResult.message = stripAnsi(e.matcherResult.message); + expect.soft(e.matcherResult).toEqual({ + actual: 'unchecked', + expected: 'indeterminate', + message: expect.stringContaining(`Timed out 1ms waiting for expect(locator).toBeChecked({ indeterminate: true })`), + 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({ indeterminate: true }) + +Locator: locator('#unchecked') +Expected: indeterminate +Received: unchecked Call log`); }