Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(toBeChecked): allow mixed expectation #34247

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/src/api/class-locator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions docs/src/api/class-locatorassertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-%%
Expand Down
5 changes: 3 additions & 2 deletions packages/playwright-core/src/client/locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
return await this._frame.isChecked(this._selector, { strict: true, ...options });
async isChecked(options?: { checked?: boolean | 'mixed' } & TimeoutOptions): Promise<boolean> {
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<boolean> {
Expand Down
1 change: 1 addition & 0 deletions packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1591,6 +1591,7 @@ scheme.FrameInputValueResult = tObject({
});
scheme.FrameIsCheckedParams = tObject({
selector: tString,
checked: tOptional(tEnum(['checked', 'unchecked', 'mixed'])),
strict: tOptional(tBoolean),
timeout: tOptional(tNumber),
});
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/server/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -777,7 +777,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {

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;
Expand Down
10 changes: 5 additions & 5 deletions packages/playwright-core/src/server/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1336,19 +1336,19 @@ export class Frame extends SdkObject {
}

async isDisabled(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}, scope?: dom.ElementHandle): Promise<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
return await this._elementState(metadata, selector, options?.checked || 'lax-checked', options, scope);
}

async hover(metadata: CallMetadata, selector: string, options: types.PointerActionOptions & types.PointerActionWaitOptions = {}) {
Expand Down
21 changes: 15 additions & 6 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' | 'mixed' | 'lax-checked' | 'stable';
export type ElementStateWithoutStable = Exclude<ElementState, 'stable'>;
export type ElementStateQueryResult = { matches: boolean, received?: string | 'error:notconnected' };

Expand Down Expand Up @@ -646,14 +646,25 @@ 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 {
matches: need === checked,
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}"`);
}

Expand Down Expand Up @@ -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') {
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',
isNot: !action.checked,
timeout: kActionTimeout,
});
Expand Down
2 changes: 2 additions & 0 deletions packages/playwright-core/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions packages/playwright/src/matchers/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/playwright/types/test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions packages/protocol/src/channels.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
6 changes: 6 additions & 0 deletions packages/protocol/src/protocol.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2128,6 +2128,12 @@ Frame:
isChecked:
parameters:
selector: string
checked:
type: enum?
literals:
- checked
- unchecked
- mixed
strict: boolean?
timeout: number?
returns:
Expand Down
14 changes: 14 additions & 0 deletions tests/page/expect-boolean.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ test.describe('toBeChecked', () => {
await expect(locator).not.toBeChecked({ checked: false });
});

test('with checked:mixed', async ({ page }) => {
await page.setContent('<input type=checkbox aria-checked=mixed></input>');
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('<input type=checkbox></input>');
const locator = page.locator('input');
Expand Down Expand Up @@ -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('<input type=checkbox></input>');
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('<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({ 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`);

}
Expand Down
26 changes: 26 additions & 0 deletions tests/page/locator-convenience.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<input type="checkbox" checked>`);
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(`<div>A</div><div>B</div><div>C</div>`);
expect(await page.locator('div').allTextContents()).toEqual(['A', 'B', 'C']);
Expand Down
Loading