Skip to content

Commit 5a071b8

Browse files
authored
feat(toHaveCss) Overload toHaveCSS matcher to accept React.CSSProperties (#38617)
1 parent 1abf1d2 commit 5a071b8

File tree

10 files changed

+146
-20
lines changed

10 files changed

+146
-20
lines changed

docs/src/actionability.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ Playwright includes auto-retrying assertions that remove flakiness by waiting un
6666
| [`method: LocatorAssertions.toHaveAttribute`] | Element has a DOM attribute |
6767
| [`method: LocatorAssertions.toHaveClass`] | Element has a class property |
6868
| [`method: LocatorAssertions.toHaveCount`] | List has exact number of children |
69-
| [`method: LocatorAssertions.toHaveCSS`] | Element has CSS property |
69+
| [`method: LocatorAssertions.toHaveCSS#1`] | Element has CSS property |
7070
| [`method: LocatorAssertions.toHaveId`] | Element has an ID |
7171
| [`method: LocatorAssertions.toHaveJSProperty`] | Element has a JavaScript property |
7272
| [`method: LocatorAssertions.toHaveText`] | Element matches text |

docs/src/api/class-locatorassertions.md

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ Expected count.
351351
* since: v1.20
352352
* langs: python
353353

354-
The opposite of [`method: LocatorAssertions.toHaveCSS`].
354+
The opposite of [`method: LocatorAssertions.toHaveCSS#1`].
355355

356356
### param: LocatorAssertions.NotToHaveCSS.name
357357
* since: v1.18
@@ -1694,7 +1694,7 @@ Expected count.
16941694
### option: LocatorAssertions.toHaveCount.timeout = %%-csharp-java-python-assertions-timeout-%%
16951695
* since: v1.18
16961696

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

1734-
### param: LocatorAssertions.toHaveCSS.name
1734+
### param: LocatorAssertions.toHaveCSS#1.name
17351735
* since: v1.18
17361736
- `name` <[string]>
17371737

17381738
CSS property name.
17391739

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

17441744
CSS property value.
17451745

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

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

1752+
## async method: LocatorAssertions.toHaveCSS#2
1753+
* since: v1.58
1754+
* langs: js
1755+
1756+
Ensures the [Locator] resolves to an element with the given computed CSS properties.
1757+
1758+
:::note
1759+
The `CSSProperties` object parameter for toHaveCSS requires `react` to be installed for type checking.
1760+
:::
1761+
1762+
**Usage**
1763+
1764+
```js
1765+
const locator = page.getByRole('button');
1766+
await expect(locator).toHaveCSS({
1767+
display: 'flex',
1768+
backgroundColor: 'rgb(255, 0, 0)'
1769+
});
1770+
```
1771+
1772+
### param: LocatorAssertions.toHaveCSS#2.styles
1773+
* since: v1.58
1774+
- `styles` <[CSSProperties]>
1775+
1776+
CSS properties object.
1777+
1778+
### option: LocatorAssertions.toHaveCSS#2.timeout = %%-js-assertions-timeout-%%
1779+
* since: v1.58
1780+
17521781
## async method: LocatorAssertions.toHaveId
17531782
* since: v1.20
17541783
* langs:

docs/src/release-notes-js.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3154,7 +3154,7 @@ List of all new assertions:
31543154
- [`expect(locator).toHaveAttribute(name, value)`](./api/class-locatorassertions#locator-assertions-to-have-attribute)
31553155
- [`expect(locator).toHaveClass(expected)`](./api/class-locatorassertions#locator-assertions-to-have-class)
31563156
- [`expect(locator).toHaveCount(count)`](./api/class-locatorassertions#locator-assertions-to-have-count)
3157-
- [`expect(locator).toHaveCSS(name, value)`](./api/class-locatorassertions#locator-assertions-to-have-css)
3157+
- [`expect(locator).toHaveCSS(name, value)`](./api/class-locatorassertions#locator-assertions-to-have-css-1)
31583158
- [`expect(locator).toHaveId(id)`](./api/class-locatorassertions#locator-assertions-to-have-id)
31593159
- [`expect(locator).toHaveJSProperty(name, value)`](./api/class-locatorassertions#locator-assertions-to-have-js-property)
31603160
- [`expect(locator).toHaveText(expected, options)`](./api/class-locatorassertions#locator-assertions-to-have-text)

docs/src/test-assertions-csharp-java-python.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ title: "Assertions"
2424
| [`method: LocatorAssertions.toHaveAttribute`] | Element has a DOM attribute |
2525
| [`method: LocatorAssertions.toHaveClass`] | Element has a class property |
2626
| [`method: LocatorAssertions.toHaveCount`] | List has exact number of children |
27-
| [`method: LocatorAssertions.toHaveCSS`] | Element has CSS property |
27+
| [`method: LocatorAssertions.toHaveCSS#1`] | Element has CSS property |
2828
| [`method: LocatorAssertions.toHaveId`] | Element has an ID |
2929
| [`method: LocatorAssertions.toHaveJSProperty`] | Element has a JavaScript property |
3030
| [`method: LocatorAssertions.toHaveRole`] | Element has a specific [ARIA role](https://www.w3.org/TR/wai-aria-1.2/#roles) |

docs/src/test-assertions-js.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Note that retrying assertions are async, so you must `await` them.
4646
| [await expect(locator).toHaveAttribute()](./api/class-locatorassertions.md#locator-assertions-to-have-attribute) | Element has a DOM attribute |
4747
| [await expect(locator).toHaveClass()](./api/class-locatorassertions.md#locator-assertions-to-have-class) | Element has specified CSS class property |
4848
| [await expect(locator).toHaveCount()](./api/class-locatorassertions.md#locator-assertions-to-have-count) | List has exact number of children |
49-
| [await expect(locator).toHaveCSS()](./api/class-locatorassertions.md#locator-assertions-to-have-css) | Element has CSS property |
49+
| [await expect(locator).toHaveCSS()](./api/class-locatorassertions.md#locator-assertions-to-have-css-1) | Element has CSS property |
5050
| [await expect(locator).toHaveId()](./api/class-locatorassertions.md#locator-assertions-to-have-id) | Element has an ID |
5151
| [await expect(locator).toHaveJSProperty()](./api/class-locatorassertions.md#locator-assertions-to-have-js-property) | Element has a JavaScript property |
5252
| [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) |

packages/playwright-core/src/utils/isomorphic/stringUtils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ export function toSnakeCase(name: string): string {
4747
return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/([A-Z])([A-Z][a-z])/g, '$1_$2').toLowerCase();
4848
}
4949

50+
export function toKebabCase(name: string): string {
51+
// E.g. backgroundColor => background-color.
52+
return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/([A-Z])([A-Z][a-z])/g, '$1-$2').toLowerCase();
53+
}
54+
5055
export function formatObject(value: any, indent = ' ', mode: 'multiline' | 'oneline' = 'multiline'): string {
5156
if (typeof value === 'string')
5257
return escapeWithQuotes(value, '\'');

packages/playwright/src/matchers/matchers.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

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

2020
import { expectTypes } from '../util';
@@ -26,12 +26,13 @@ import { toHaveScreenshotStepTitle } from './toMatchSnapshot';
2626
import { takeFirst } from '../common/config';
2727
import { currentTestInfo } from '../common/globals';
2828
import { TestInfoImpl } from '../worker/testInfo';
29-
import { formatMatcherMessage } from './matcherHint';
29+
import { formatMatcherMessage, MatcherResult } from './matcherHint';
3030

3131
import type { ExpectMatcherState } from '../../types/test';
3232
import type { TestStepInfoImpl } from '../worker/testInfo';
3333
import type { APIResponse, Locator, Frame, Page } from 'playwright-core';
3434
import type { FrameExpectParams } from 'playwright-core/lib/client/types';
35+
import type { CSSProperties } from '../../types/test';
3536

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

@@ -308,17 +309,41 @@ export function toHaveCount(
308309
}, expected, options);
309310
}
310311

312+
export function toHaveCSS(this: ExpectMatcherState, locator: LocatorEx, name: string, expected: string | RegExp, options?: { timeout?: number }): Promise<MatcherResult<any, any>>;
313+
export function toHaveCSS(this: ExpectMatcherState, locator: LocatorEx, styles: CSSProperties, options?: { timeout?: number }): Promise<MatcherResult<any, any>>;
311314
export function toHaveCSS(
312315
this: ExpectMatcherState,
313316
locator: LocatorEx,
314-
name: string,
315-
expected: string | RegExp,
317+
nameOrStyles: string | CSSProperties,
318+
expectedOrOptions?: (string | RegExp) | { timeout?: number },
316319
options?: { timeout?: number },
317320
) {
318-
return toMatchText.call(this, 'toHaveCSS', locator, 'Locator', async (isNot, timeout) => {
319-
const expectedText = serializeExpectedTextValues([expected]);
320-
return await locator._expect('to.have.css', { expressionArg: name, expectedText, isNot, timeout });
321-
}, expected, options);
321+
if (typeof nameOrStyles === 'string') {
322+
if (expectedOrOptions === undefined)
323+
throw new Error(`toHaveCSS expected value must be provided`);
324+
const propertyName = nameOrStyles as string;
325+
const expected = expectedOrOptions as string | RegExp;
326+
return toMatchText.call(this, 'toHaveCSS', locator, 'Locator', async (isNot, timeout) => {
327+
const expectedText = serializeExpectedTextValues([expected]);
328+
return await locator._expect('to.have.css', { expressionArg: propertyName, expectedText, isNot, timeout });
329+
}, expected, options);
330+
} else {
331+
const styles = nameOrStyles as CSSProperties;
332+
const options = expectedOrOptions as { timeout?: number };
333+
return toEqual.call(this, 'toHaveCSS', locator, 'Locator', async (isNot, timeout) => {
334+
const results: any[] = [];
335+
for (const [name, value] of Object.entries(styles)) {
336+
const propertyName = convertStylePropertyNameFromJsToCss(name);
337+
const expected = value as string;
338+
const expectedText = serializeExpectedTextValues([expected]);
339+
const result = await locator._expect('to.have.css', { expressionArg: propertyName, expectedText, isNot, timeout });
340+
results.push(result);
341+
if (!result.matches)
342+
return result;
343+
}
344+
return { matches: true };
345+
}, styles, options);
346+
}
322347
}
323348

324349
export function toHaveId(
@@ -506,3 +531,11 @@ export function computeMatcherTitleSuffix(matcherName: string, receiver: any, ar
506531
}
507532
return {};
508533
}
534+
535+
function convertStylePropertyNameFromJsToCss(name: string): string {
536+
const vendorMatch = name.match(/^(Webkit|Moz|Ms|O)([A-Z].*)/);
537+
if (vendorMatch)
538+
return `-${toKebabCase(name)}`;
539+
540+
return toKebabCase(name);
541+
}

packages/playwright/types/test.d.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
import type { APIRequestContext, Browser, BrowserContext, BrowserContextOptions, Page, PageAgent, LaunchOptions, ViewportSize, Geolocation, HTTPCredentials, Locator, APIResponse, PageScreenshotOptions } from 'playwright-core';
1919
export * from 'playwright-core';
2020

21+
// @ts-ignore ReactCSSProperties will be any if react is not installed
22+
type ReactCSSProperties = import('react').CSSProperties;
23+
export type CSSProperties = keyof ReactCSSProperties extends string ? ReactCSSProperties : never;
24+
2125
export type BlobReporterOptions = { outputDir?: string, fileName?: string };
2226
export type ListReporterOptions = { printSteps?: boolean };
2327
export type JUnitReporterOptions = { outputFile?: string, stripANSIControlSequences?: boolean, includeProjectInTestName?: boolean };
@@ -9173,6 +9177,32 @@ interface LocatorAssertions {
91739177
timeout?: number;
91749178
}): Promise<void>;
91759179

9180+
/**
9181+
* Ensures the [Locator](https://playwright.dev/docs/api/class-locator) resolves to an element with the given computed
9182+
* CSS properties.
9183+
*
9184+
* **NOTE** The `CSSProperties` object parameter for toHaveCSS requires `react` to be installed for type checking.
9185+
*
9186+
* **Usage**
9187+
*
9188+
* ```js
9189+
* const locator = page.getByRole('button');
9190+
* await expect(locator).toHaveCSS({
9191+
* display: 'flex',
9192+
* backgroundColor: 'rgb(255, 0, 0)'
9193+
* });
9194+
* ```
9195+
*
9196+
* @param styles CSS properties object.
9197+
* @param options
9198+
*/
9199+
toHaveCSS(styles: CSSProperties, options?: {
9200+
/**
9201+
* Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`.
9202+
*/
9203+
timeout?: number;
9204+
}): Promise<void>;
9205+
91769206
/**
91779207
* Ensures the [Locator](https://playwright.dev/docs/api/class-locator) points to an element with the given DOM Node
91789208
* ID.

tests/page/expect-misc.spec.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17+
import { CSSProperties } from 'packages/playwright-test';
1718
import { stripAnsi } from '../config/utils';
1819
import { test, expect } from './pageTest';
1920

@@ -507,17 +508,41 @@ Timeout: 1000ms`);
507508
});
508509

509510
test.describe('toHaveCSS', () => {
510-
test('pass', async ({ page }) => {
511+
test('pass with css property', async ({ page }) => {
511512
await page.setContent('<div id=node style="color: rgb(255, 0, 0)">Text content</div>');
512513
const locator = page.locator('#node');
513514
await expect(locator).toHaveCSS('color', 'rgb(255, 0, 0)');
514515
});
515516

516-
test('custom css properties', async ({ page }) => {
517+
test('pass with custom css property', async ({ page }) => {
517518
await page.setContent('<div id=node style="--custom-color-property:#FF00FF;">Text content</div>');
518519
const locator = page.locator('#node');
519520
await expect(locator).toHaveCSS('--custom-color-property', '#FF00FF');
520521
});
522+
523+
test('pass with CSSPProperties object', async ({ page }) => {
524+
await page.setContent('<div id=node style="color: rgb(255, 0, 0); border: 1px solid rgb(0, 255, 0);">Text content</div>');
525+
const locator = page.locator('#node');
526+
await expect(locator).toHaveCSS({ 'color': 'rgb(255, 0, 0)', 'border': '1px solid rgb(0, 255, 0)' });
527+
});
528+
529+
test('pass with CSSPProperties object with camelCased properties', async ({ page }) => {
530+
await page.setContent('<div id=node style="background-color: red">Text content</div>');
531+
const locator = page.locator('#node');
532+
await expect(locator).toHaveCSS({ 'backgroundColor': 'rgb(255, 0, 0)' });
533+
});
534+
535+
test('pass with CSSPProperties object with vendor-prefixed properties', async ({ page }) => {
536+
await page.setContent('<div id=node style="-webkit-transform: rotate(45deg);">Text content</div>');
537+
const locator = page.locator('#node');
538+
await expect(locator).toHaveCSS({ 'WebkitTransform': 'matrix(0.707107, 0.707107, -0.707107, 0.707107, 0, 0)' });
539+
});
540+
541+
test('pass with CSSPProperties object with custom properties', async ({ page }) => {
542+
await page.setContent('<div id=node style="--my-color: blue;">Text content</div>');
543+
const locator = page.locator('#node');
544+
await expect(locator).toHaveCSS({ '--my-color': 'blue' } as CSSProperties);
545+
});
521546
});
522547

523548
test.describe('toHaveId', () => {

utils/generate_types/overrides-test.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
import type { APIRequestContext, Browser, BrowserContext, BrowserContextOptions, Page, PageAgent, LaunchOptions, ViewportSize, Geolocation, HTTPCredentials, Locator, APIResponse, PageScreenshotOptions } from 'playwright-core';
1818
export * from 'playwright-core';
1919

20+
// @ts-ignore ReactCSSProperties will be any if react is not installed
21+
type ReactCSSProperties = import('react').CSSProperties;
22+
export type CSSProperties = keyof ReactCSSProperties extends string ? ReactCSSProperties : never;
23+
2024
export type BlobReporterOptions = { outputDir?: string, fileName?: string };
2125
export type ListReporterOptions = { printSteps?: boolean };
2226
export type JUnitReporterOptions = { outputFile?: string, stripANSIControlSequences?: boolean, includeProjectInTestName?: boolean };

0 commit comments

Comments
 (0)