Skip to content
Merged
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
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#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);
}
30 changes: 30 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,32 @@ 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.
*
* **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 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