From 8dc86d705b9b927125b1831c5aceab468c874f82 Mon Sep 17 00:00:00 2001 From: Ichizo Umehara Date: Wed, 17 Dec 2025 22:58:59 -0500 Subject: [PATCH] feat(toHaveCss) Overload toHaveCSS matcher to accept React.CSSProperties References #35113 --- docs/src/api/class-locatorassertions.md | 14 ++++++ docs/src/test-assertions-js.md | 2 +- .../src/utils/isomorphic/stringUtils.ts | 5 ++ packages/playwright/src/matchers/matchers.ts | 50 ++++++++++++++++--- packages/playwright/types/test.d.ts | 15 ++++++ tests/page/expect-misc.spec.ts | 24 +++++++++ utils/generate_types/overrides-test.d.ts | 7 +++ 7 files changed, 110 insertions(+), 7 deletions(-) diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index c2edbf7533c6c..d74deb87b515d 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -1706,6 +1706,14 @@ Ensures the [Locator] resolves to an element with the given computed CSS style. ```js const locator = page.getByRole('button'); await expect(locator).toHaveCSS('display', 'flex'); + +await expect(locator).toHaveCSS({ + display: 'flex', + backgroundColor: 'rgb(255, 0, 0)', + fontSize: '16px' +}); + +await expect(locator).toHaveCSS({ '--custom-color': 'blue' } as React.CSSProperties); ``` ```java @@ -1743,6 +1751,12 @@ CSS property name. CSS property value. +### param: LocatorAssertions.toHaveCSS.styles +* since: v1.58 +- `styles` <[React.CSSProperties]> + +CSS properties object. + ### option: LocatorAssertions.toHaveCSS.timeout = %%-js-assertions-timeout-%% * since: v1.18 diff --git a/docs/src/test-assertions-js.md b/docs/src/test-assertions-js.md index c809dce782764..0426d8f590e7a 100644 --- a/docs/src/test-assertions-js.md +++ b/docs/src/test-assertions-js.md @@ -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) | Element has CSS property / React.CSSProperties | | [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) | diff --git a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts index 0232d0819c8dc..fae26008da9a5 100644 --- a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts @@ -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').toLowerCase(); +} + export function formatObject(value: any, indent = ' ', mode: 'multiline' | 'oneline' = 'multiline'): string { if (typeof value === 'string') return escapeWithQuotes(value, '\''); diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 01c86eb5907b2..c7fa48c968e28 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -32,6 +32,7 @@ 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 'react'; export type ExpectMatcherStateInternal = ExpectMatcherState & { _stepInfo?: TestStepInfoImpl }; @@ -308,17 +309,39 @@ export function toHaveCount( }, expected, options); } +export function toHaveCSS(this: ExpectMatcherState, locator: LocatorEx, name: string, expected: string | RegExp, options?: { timeout?: number }): Promise>; +export function toHaveCSS(this: ExpectMatcherState, locator: LocatorEx, styles: CSSProperties, options?: { timeout?: number }): Promise>; 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 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: nameOrStyles, 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 [property, value] of Object.entries(styles)) { + const cssProperty = reactCSSPropertyToCSSName(property); + const expectedText = serializeExpectedTextValues([value as string]); + const result = await locator._expect('to.have.css', { expressionArg: cssProperty, expectedText, isNot, timeout }); + results.push(result); + if (!result.matches) + return result; + } + return { matches: true }; + }, styles, options); + } } export function toHaveId( @@ -506,3 +529,18 @@ export function computeMatcherTitleSuffix(matcherName: string, receiver: any, ar } return {}; } + +function reactCSSPropertyToCSSName(name: keyof CSSProperties | string): string { + const isCustomProperty = name.startsWith('--'); + if (isCustomProperty) + return name; + + const vendorMatch = name.match(/^(Webkit|Moz|Ms|O)([A-Z].*)/); + if (vendorMatch) { + const prefix = vendorMatch[1].toLowerCase(); + const property = vendorMatch[2]; + return `-${prefix}-${toKebabCase(property)}`; + } + + return toKebabCase(name); +} diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index aa94fed21ef2b..57554584e4e00 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -16,6 +16,7 @@ */ import type { APIRequestContext, Browser, BrowserContext, BrowserContextOptions, Page, LaunchOptions, ViewportSize, Geolocation, HTTPCredentials, Locator, APIResponse, PageScreenshotOptions } from 'playwright-core'; +import type { CSSProperties } from 'react'; export * from 'playwright-core'; export type BlobReporterOptions = { outputDir?: string, fileName?: string }; @@ -8471,6 +8472,20 @@ export type Expect = { declare global { export namespace PlaywrightTest { export interface Matchers { + /** + * Ensures the [Locator](https://playwright.dev/docs/api/class-locator) resolves to an element with given CSS values. + * + * **Usage** + * + * ```js + * const locator = page.getByRole('button'); + * await expect(locator).toHaveCSS({ backgroundColor: 'red', color: 'white' }); + * ``` + * + * @param styles CSS property names and values as an object. + * @param options + */ + toHaveCSS(styles: CSSProperties, options?: { timeout?: number }): Promise; } } } diff --git a/tests/page/expect-misc.spec.ts b/tests/page/expect-misc.spec.ts index e550d3ca72ee9..4db34a439fd0f 100644 --- a/tests/page/expect-misc.spec.ts +++ b/tests/page/expect-misc.spec.ts @@ -518,6 +518,30 @@ test.describe('toHaveCSS', () => { const locator = page.locator('#node'); await expect(locator).toHaveCSS('--custom-color-property', '#FF00FF'); }); + + test('pass with React.CSSPProperties', async ({ page }) => { + await page.setContent('
Text content
'); + const locator = page.locator('#node'); + await expect(locator).toHaveCSS({ 'color': 'rgb(255, 0, 0)', 'border': '1px solid rgb(0, 255, 0)' }); + }); + + test('pass with React.CSSPProperties that are camelCase', async ({ page }) => { + await page.setContent('
Text content
'); + const locator = page.locator('#node'); + await expect(locator).toHaveCSS({ 'backgroundColor': 'rgb(255, 0, 0)' }); + }); + + test('vendor React.CSSPProperties that are vendor-prefixed properties', async ({ page }) => { + await page.setContent('
Text content
'); + const locator = page.locator('#node'); + await expect(locator).toHaveCSS({ 'WebkitTransform': 'matrix(0.707107, 0.707107, -0.707107, 0.707107, 0, 0)' }); + }); + + test('custom React.CSSPProperties that are custom properties', async ({ page }) => { + await page.setContent('
Text content
'); + const locator = page.locator('#node'); + await expect(locator).toHaveCSS({ '--my-color': 'blue' } as React.CSSProperties); + }); }); test.describe('toHaveId', () => { diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index 4b2bec33901bf..3b433cefd04aa 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -15,6 +15,7 @@ */ import type { APIRequestContext, Browser, BrowserContext, BrowserContextOptions, Page, LaunchOptions, ViewportSize, Geolocation, HTTPCredentials, Locator, APIResponse, PageScreenshotOptions } from 'playwright-core'; +import type { CSSProperties } from 'react'; export * from 'playwright-core'; export type BlobReporterOptions = { outputDir?: string, fileName?: string }; @@ -492,6 +493,12 @@ export type Expect = { declare global { export namespace PlaywrightTest { export interface Matchers { + /** + * Ensures the Locator resolves to an element with given CSS values. + * + * @param styles CSS property names and values as an object. + */ + toHaveCSS(styles: CSSProperties, options?: { timeout?: number }): Promise; } } }