Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion docs/src/actionability.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Playwright includes auto-retrying assertions that remove flakiness by waiting un
| [`method: LocatorAssertions.toHaveAttribute`] | Element has a DOM attribute |
| [`method: LocatorAssertions.toHaveClass`] | Element has a class property |
| [`method: LocatorAssertions.toHaveCount`] | List has exact number of children |
| [`method: LocatorAssertions.toHaveCSS`] | Element has CSS property |
| [`method: LocatorAssertions.toHaveCSS#1`] | Element has CSS property |
| [`method: LocatorAssertions.toHaveId`] | Element has an ID |
| [`method: LocatorAssertions.toHaveJSProperty`] | Element has a JavaScript property |
| [`method: LocatorAssertions.toHaveText`] | Element matches text |
Expand Down
41 changes: 35 additions & 6 deletions docs/src/api/class-locatorassertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ Expected count.
* since: v1.20
* langs: python

The opposite of [`method: LocatorAssertions.toHaveCSS`].
The opposite of [`method: LocatorAssertions.toHaveCSS#1`].

### param: LocatorAssertions.NotToHaveCSS.name
* since: v1.18
Expand Down Expand Up @@ -1694,7 +1694,7 @@ Expected count.
### option: LocatorAssertions.toHaveCount.timeout = %%-csharp-java-python-assertions-timeout-%%
* since: v1.18

## async method: LocatorAssertions.toHaveCSS
## async method: LocatorAssertions.toHaveCSS#1
* since: v1.20
* langs:
- alias-java: hasCSS
Expand Down Expand Up @@ -1731,24 +1731,53 @@ var locator = Page.GetByRole(AriaRole.Button);
await Expect(locator).ToHaveCSSAsync("display", "flex");
```

### param: LocatorAssertions.toHaveCSS.name
### param: LocatorAssertions.toHaveCSS#1.name
* since: v1.18
- `name` <[string]>

CSS property name.

### param: LocatorAssertions.toHaveCSS.value
### param: LocatorAssertions.toHaveCSS#1.value
* since: v1.18
- `value` <[string]|[RegExp]>

CSS property value.

### option: LocatorAssertions.toHaveCSS.timeout = %%-js-assertions-timeout-%%
### option: LocatorAssertions.toHaveCSS#1.timeout = %%-js-assertions-timeout-%%
* since: v1.18

### option: LocatorAssertions.toHaveCSS.timeout = %%-csharp-java-python-assertions-timeout-%%
### option: LocatorAssertions.toHaveCSS#1.timeout = %%-csharp-java-python-assertions-timeout-%%
* since: v1.18

## async method: LocatorAssertions.toHaveCSS#2
* since: v1.58
* langs: js

Ensures the [Locator] resolves to an element with the given computed CSS properties.

:::note
The `CSSProperties` object parameter for toHaveCSS requires `react` to be installed for type checking.
:::

**Usage**

```js
const locator = page.getByRole('button');
await expect(locator).toHaveCSS({
display: 'flex',
backgroundColor: 'rgb(255, 0, 0)'
});
```

### param: LocatorAssertions.toHaveCSS#2.styles
* since: v1.58
- `styles` <[CSSProperties]>

CSS properties object.

### option: LocatorAssertions.toHaveCSS#2.timeout = %%-js-assertions-timeout-%%
* since: v1.58

## async method: LocatorAssertions.toHaveId
* since: v1.20
* langs:
Expand Down
2 changes: 1 addition & 1 deletion docs/src/release-notes-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -3154,7 +3154,7 @@ List of all new assertions:
- [`expect(locator).toHaveAttribute(name, value)`](./api/class-locatorassertions#locator-assertions-to-have-attribute)
- [`expect(locator).toHaveClass(expected)`](./api/class-locatorassertions#locator-assertions-to-have-class)
- [`expect(locator).toHaveCount(count)`](./api/class-locatorassertions#locator-assertions-to-have-count)
- [`expect(locator).toHaveCSS(name, value)`](./api/class-locatorassertions#locator-assertions-to-have-css)
- [`expect(locator).toHaveCSS(name, value)`](./api/class-locatorassertions.md#locator-assertions-to-have-css-1)
- [`expect(locator).toHaveId(id)`](./api/class-locatorassertions#locator-assertions-to-have-id)
- [`expect(locator).toHaveJSProperty(name, value)`](./api/class-locatorassertions#locator-assertions-to-have-js-property)
- [`expect(locator).toHaveText(expected, options)`](./api/class-locatorassertions#locator-assertions-to-have-text)
Expand Down
2 changes: 1 addition & 1 deletion docs/src/test-assertions-csharp-java-python.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ title: "Assertions"
| [`method: LocatorAssertions.toHaveAttribute`] | Element has a DOM attribute |
| [`method: LocatorAssertions.toHaveClass`] | Element has a class property |
| [`method: LocatorAssertions.toHaveCount`] | List has exact number of children |
| [`method: LocatorAssertions.toHaveCSS`] | Element has CSS property |
| [`method: LocatorAssertions.toHaveCSS#1`] | Element has CSS property |
| [`method: LocatorAssertions.toHaveId`] | Element has an ID |
| [`method: LocatorAssertions.toHaveJSProperty`] | Element has a JavaScript property |
| [`method: LocatorAssertions.toHaveRole`] | Element has a specific [ARIA role](https://www.w3.org/TR/wai-aria-1.2/#roles) |
Expand Down
2 changes: 1 addition & 1 deletion docs/src/test-assertions-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Note that retrying assertions are async, so you must `await` them.
| [await expect(locator).toHaveAttribute()](./api/class-locatorassertions.md#locator-assertions-to-have-attribute) | Element has a DOM attribute |
| [await expect(locator).toHaveClass()](./api/class-locatorassertions.md#locator-assertions-to-have-class) | Element has specified CSS class property |
| [await expect(locator).toHaveCount()](./api/class-locatorassertions.md#locator-assertions-to-have-count) | List has exact number of children |
| [await expect(locator).toHaveCSS()](./api/class-locatorassertions.md#locator-assertions-to-have-css) | Element has CSS property |
| [await expect(locator).toHaveCSS()](./api/class-locatorassertions.md#locator-assertions-to-have-css-1) | Element has CSS property |
| [await expect(locator).toHaveId()](./api/class-locatorassertions.md#locator-assertions-to-have-id) | Element has an ID |
| [await expect(locator).toHaveJSProperty()](./api/class-locatorassertions.md#locator-assertions-to-have-js-property) | Element has a JavaScript property |
| [await expect(locator).toHaveRole()](./api/class-locatorassertions.md#locator-assertions-to-have-role) | Element has a specific [ARIA role](https://www.w3.org/TR/wai-aria-1.2/#roles) |
Expand Down
5 changes: 5 additions & 0 deletions packages/playwright-core/src/utils/isomorphic/stringUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export function toSnakeCase(name: string): string {
return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/([A-Z])([A-Z][a-z])/g, '$1_$2').toLowerCase();
}

export function toKebabCase(name: string): string {
// E.g. backgroundColor => background-color.
return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/([A-Z])([A-Z][a-z])/g, '$1-$2').toLowerCase();
}

export function formatObject(value: any, indent = ' ', mode: 'multiline' | 'oneline' = 'multiline'): string {
if (typeof value === 'string')
return escapeWithQuotes(value, '\'');
Expand Down
49 changes: 41 additions & 8 deletions packages/playwright/src/matchers/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { asLocatorDescription, constructURLBasedOnBaseURL, isRegExp, isString, isTextualMimeType, pollAgainstDeadline, serializeExpectedTextValues } from 'playwright-core/lib/utils';
import { asLocatorDescription, constructURLBasedOnBaseURL, isRegExp, isString, isTextualMimeType, pollAgainstDeadline, serializeExpectedTextValues, toKebabCase } from 'playwright-core/lib/utils';
import { colors } from 'playwright-core/lib/utils';

import { expectTypes } from '../util';
Expand All @@ -26,12 +26,13 @@ import { toHaveScreenshotStepTitle } from './toMatchSnapshot';
import { takeFirst } from '../common/config';
import { currentTestInfo } from '../common/globals';
import { TestInfoImpl } from '../worker/testInfo';
import { formatMatcherMessage } from './matcherHint';
import { formatMatcherMessage, MatcherResult } from './matcherHint';

import type { ExpectMatcherState } from '../../types/test';
import type { TestStepInfoImpl } from '../worker/testInfo';
import type { APIResponse, Locator, Frame, Page } from 'playwright-core';
import type { FrameExpectParams } from 'playwright-core/lib/client/types';
import type { CSSProperties } from '../../types/test';

export type ExpectMatcherStateInternal = ExpectMatcherState & { _stepInfo?: TestStepInfoImpl };

Expand Down Expand Up @@ -308,17 +309,41 @@ export function toHaveCount(
}, expected, options);
}

export function toHaveCSS(this: ExpectMatcherState, locator: LocatorEx, name: string, expected: string | RegExp, options?: { timeout?: number }): Promise<MatcherResult<any, any>>;
export function toHaveCSS(this: ExpectMatcherState, locator: LocatorEx, styles: CSSProperties, options?: { timeout?: number }): Promise<MatcherResult<any, any>>;
export function toHaveCSS(
this: ExpectMatcherState,
locator: LocatorEx,
name: string,
expected: string | RegExp,
nameOrStyles: string | CSSProperties,
expectedOrOptions?: (string | RegExp) | { timeout?: number },
options?: { timeout?: number },
) {
return toMatchText.call(this, 'toHaveCSS', locator, 'Locator', async (isNot, timeout) => {
const expectedText = serializeExpectedTextValues([expected]);
return await locator._expect('to.have.css', { expressionArg: name, expectedText, isNot, timeout });
}, expected, options);
if (typeof nameOrStyles === 'string') {
if (expectedOrOptions === undefined)
throw new Error(`toHaveCSS expected value must be provided`);
const propertyName = nameOrStyles as string;
const expected = expectedOrOptions as string | RegExp;
return toMatchText.call(this, 'toHaveCSS', locator, 'Locator', async (isNot, timeout) => {
const expectedText = serializeExpectedTextValues([expected]);
return await locator._expect('to.have.css', { expressionArg: propertyName, expectedText, isNot, timeout });
}, expected, options);
} else {
const styles = nameOrStyles as CSSProperties;
const options = expectedOrOptions as { timeout?: number };
return toEqual.call(this, 'toHaveCSS', locator, 'Locator', async (isNot, timeout) => {
const results: any[] = [];
for (const [name, value] of Object.entries(styles)) {
const propertyName = convertStylePropertyNameFromJsToCss(name);
const expected = value as string;
const expectedText = serializeExpectedTextValues([expected]);
const result = await locator._expect('to.have.css', { expressionArg: propertyName, expectedText, isNot, timeout });
results.push(result);
if (!result.matches)
return result;
}
return { matches: true };
}, styles, options);
}
}

export function toHaveId(
Expand Down Expand Up @@ -506,3 +531,11 @@ export function computeMatcherTitleSuffix(matcherName: string, receiver: any, ar
}
return {};
}

function convertStylePropertyNameFromJsToCss(name: string): string {
const vendorMatch = name.match(/^(Webkit|Moz|Ms|O)([A-Z].*)/);
if (vendorMatch)
return `-${toKebabCase(name)}`;

return toKebabCase(name);
}
28 changes: 28 additions & 0 deletions packages/playwright/types/test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
import type { APIRequestContext, Browser, BrowserContext, BrowserContextOptions, Page, LaunchOptions, ViewportSize, Geolocation, HTTPCredentials, Locator, APIResponse, PageScreenshotOptions } from 'playwright-core';
export * from 'playwright-core';

// @ts-ignore ReactCSSProperties will be any if react is not installed
type ReactCSSProperties = import('react').CSSProperties;
export type CSSProperties = keyof ReactCSSProperties extends string ? ReactCSSProperties : never;

export type BlobReporterOptions = { outputDir?: string, fileName?: string };
export type ListReporterOptions = { printSteps?: boolean };
export type JUnitReporterOptions = { outputFile?: string, stripANSIControlSequences?: boolean, includeProjectInTestName?: boolean };
Expand Down Expand Up @@ -9150,6 +9154,30 @@ interface LocatorAssertions {
timeout?: number;
}): Promise<void>;

/**
* Ensures the [Locator](https://playwright.dev/docs/api/class-locator) resolves to an element with the given computed
* CSS properties.
*
* **Usage**
*
* ```js
* const locator = page.getByRole('button');
* await expect(locator).toHaveCSS({
* display: 'flex',
* backgroundColor: 'rgb(255, 0, 0)'
* });
* ```
*
* @param styles CSS properties object.
* @param options
*/
toHaveCSS(styles: CSSProperties, options?: {
/**
* Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`.
*/
timeout?: number;
}): Promise<void>;

/**
* Ensures the [Locator](https://playwright.dev/docs/api/class-locator) points to an element with the given DOM Node
* ID.
Expand Down
29 changes: 27 additions & 2 deletions tests/page/expect-misc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* limitations under the License.
*/

import { CSSProperties } from 'packages/playwright-test';
import { stripAnsi } from '../config/utils';
import { test, expect } from './pageTest';

Expand Down Expand Up @@ -507,17 +508,41 @@ Timeout: 1000ms`);
});

test.describe('toHaveCSS', () => {
test('pass', async ({ page }) => {
test('pass with css property', async ({ page }) => {
await page.setContent('<div id=node style="color: rgb(255, 0, 0)">Text content</div>');
const locator = page.locator('#node');
await expect(locator).toHaveCSS('color', 'rgb(255, 0, 0)');
});

test('custom css properties', async ({ page }) => {
test('pass with custom css property', async ({ page }) => {
await page.setContent('<div id=node style="--custom-color-property:#FF00FF;">Text content</div>');
const locator = page.locator('#node');
await expect(locator).toHaveCSS('--custom-color-property', '#FF00FF');
});

test('pass with CSSPProperties object', async ({ page }) => {
await page.setContent('<div id=node style="color: rgb(255, 0, 0); border: 1px solid rgb(0, 255, 0);">Text content</div>');
const locator = page.locator('#node');
await expect(locator).toHaveCSS({ 'color': 'rgb(255, 0, 0)', 'border': '1px solid rgb(0, 255, 0)' });
});

test('pass with CSSPProperties object with camelCased properties', async ({ page }) => {
await page.setContent('<div id=node style="background-color: red">Text content</div>');
const locator = page.locator('#node');
await expect(locator).toHaveCSS({ 'backgroundColor': 'rgb(255, 0, 0)' });
});

test('pass with CSSPProperties object with vendor-prefixed properties', async ({ page }) => {
await page.setContent('<div id=node style="-webkit-transform: rotate(45deg);">Text content</div>');
const locator = page.locator('#node');
await expect(locator).toHaveCSS({ 'WebkitTransform': 'matrix(0.707107, 0.707107, -0.707107, 0.707107, 0, 0)' });
});

test('pass with CSSPProperties object with custom properties', async ({ page }) => {
await page.setContent('<div id=node style="--my-color: blue;">Text content</div>');
const locator = page.locator('#node');
await expect(locator).toHaveCSS({ '--my-color': 'blue' } as CSSProperties);
});
});

test.describe('toHaveId', () => {
Expand Down
4 changes: 4 additions & 0 deletions utils/generate_types/overrides-test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
import type { APIRequestContext, Browser, BrowserContext, BrowserContextOptions, Page, LaunchOptions, ViewportSize, Geolocation, HTTPCredentials, Locator, APIResponse, PageScreenshotOptions } from 'playwright-core';
export * from 'playwright-core';

// @ts-ignore ReactCSSProperties will be any if react is not installed
type ReactCSSProperties = import('react').CSSProperties;
export type CSSProperties = keyof ReactCSSProperties extends string ? ReactCSSProperties : never;

export type BlobReporterOptions = { outputDir?: string, fileName?: string };
export type ListReporterOptions = { printSteps?: boolean };
export type JUnitReporterOptions = { outputFile?: string, stripANSIControlSequences?: boolean, includeProjectInTestName?: boolean };
Expand Down
Loading