diff --git a/docs/src/actionability.md b/docs/src/actionability.md index 3510171e5c328..cff339faffb01 100644 --- a/docs/src/actionability.md +++ b/docs/src/actionability.md @@ -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 | diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index c2edbf7533c6c..f7c5e9028f8a2 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -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 @@ -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 @@ -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: diff --git a/docs/src/release-notes-js.md b/docs/src/release-notes-js.md index 2ab8bccdd4045..1a4edb5ce2cf7 100644 --- a/docs/src/release-notes-js.md +++ b/docs/src/release-notes-js.md @@ -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) diff --git a/docs/src/test-assertions-csharp-java-python.md b/docs/src/test-assertions-csharp-java-python.md index 114de1624a8cc..520a9419b87e3 100644 --- a/docs/src/test-assertions-csharp-java-python.md +++ b/docs/src/test-assertions-csharp-java-python.md @@ -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) | diff --git a/docs/src/test-assertions-js.md b/docs/src/test-assertions-js.md index c809dce782764..7baca2e707736 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-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) | diff --git a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts index 0232d0819c8dc..cbf6a5792a1db 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').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, '\''); diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 01c86eb5907b2..03f8b9f4ac3e1 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -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'; @@ -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 }; @@ -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>; +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 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( @@ -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); +} diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index aa94fed21ef2b..8f249e634bc86 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -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 }; @@ -9150,6 +9154,32 @@ interface LocatorAssertions { timeout?: number; }): Promise; + /** + * 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; + /** * Ensures the [Locator](https://playwright.dev/docs/api/class-locator) points to an element with the given DOM Node * ID. diff --git a/tests/page/expect-misc.spec.ts b/tests/page/expect-misc.spec.ts index e550d3ca72ee9..fdf4d0faf15c1 100644 --- a/tests/page/expect-misc.spec.ts +++ b/tests/page/expect-misc.spec.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { CSSProperties } from 'packages/playwright-test'; import { stripAnsi } from '../config/utils'; import { test, expect } from './pageTest'; @@ -507,17 +508,41 @@ Timeout: 1000ms`); }); test.describe('toHaveCSS', () => { - test('pass', async ({ page }) => { + test('pass with css property', async ({ page }) => { await page.setContent('
Text content
'); 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('
Text content
'); const locator = page.locator('#node'); await expect(locator).toHaveCSS('--custom-color-property', '#FF00FF'); }); + + test('pass with CSSPProperties object', 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 CSSPProperties object with camelCased properties', async ({ page }) => { + await page.setContent('
Text content
'); + 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('
Text content
'); + 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('
Text content
'); + const locator = page.locator('#node'); + await expect(locator).toHaveCSS({ '--my-color': 'blue' } as CSSProperties); + }); }); test.describe('toHaveId', () => { diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index 4b2bec33901bf..c04ab8f47e151 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -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 };