Skip to content

Commit

Permalink
feat(toBeChecked): allow indeterminate expectation (#34269)
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman authored Jan 10, 2025
1 parent 37c2569 commit 13bdd3c
Show file tree
Hide file tree
Showing 8 changed files with 120 additions and 15 deletions.
10 changes: 10 additions & 0 deletions docs/src/api/class-locatorassertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 23 additions & 8 deletions packages/playwright-core/src/server/injected/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -41,7 +41,7 @@ import { parseYamlTemplate } from '@isomorphic/ariaSnapshot';

export type FrameExpectParams = Omit<channels.FrameExpectParams, 'expectedValue'> & { 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<ElementState, 'stable'>;
export type ElementStateQueryResult = { matches: boolean, received?: string | 'error:notconnected' };

Expand Down Expand Up @@ -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',
};
}
Expand Down Expand Up @@ -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') {
Expand Down
12 changes: 11 additions & 1 deletion packages/playwright-core/src/server/injected/roleUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
22 changes: 17 additions & 5 deletions packages/playwright/src/matchers/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
13 changes: 13 additions & 0 deletions packages/playwright/types/test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*/
Expand Down
22 changes: 22 additions & 0 deletions tests/page/expect-boolean.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ test.describe('toBeChecked', () => {
await expect(locator).not.toBeChecked({ checked: false });
});

test('with indeterminate:true', async ({ page }) => {
await page.setContent('<input type=checkbox></input>');
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('<input type=checkbox></input>');
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('<input type=checkbox></input>');
const locator = page.locator('input');
Expand Down Expand Up @@ -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('<input type=checkbox></input>');
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('<div>no inputs here</div>');
const locator2 = page.locator('input2');
Expand Down
24 changes: 23 additions & 1 deletion tests/page/expect-matcher-result.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
<input id=checked type=checkbox checked></input>
<input id=unchecked type=checkbox></input>
Expand Down Expand Up @@ -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`);

}
Expand Down

0 comments on commit 13bdd3c

Please sign in to comment.