Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
14 changes: 14 additions & 0 deletions docs/src/api/class-locatorassertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1743,6 +1751,12 @@ CSS property name.

CSS property value.

### param: LocatorAssertions.toHaveCSS.styles
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to update value parameter's type to accept React.CSSProperties rather than add a new parameter.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What makes this a little awkward is that this feature requires less parameters than before. ie. one parameter for the pojo CSS Properties, as opposed to the two parameters for the name and the value.

The current PR handles multiple possible inputs for the first argument (either the name string, or the React.CSSProperties), albeit with some questionable naming.

Should I just delete the overload signatures, and use the main function definition that has union parameters?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I was not clear. What I meant is you can use function overload with #1 and #2 suffixes. See ## method: TestInfo.fixme#1 for example. Then first overload will stay as is and the second one will have CSSProperty as the only parameter. Then in the documentation we'd have 2 entries, one per overload (see e.g. https://playwright.dev/docs/api/class-testinfo#test-info-fixme-1), you'd need to make sure that there is proper usage section that presents both signatures. Support of overloaded methods is a bit messy in our API, but CSSPropery is not much different than other overloads, so it should work.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. I kept the existing implementation untouched as the base method, and added the new method definition as #2. This way, the other documentation that references toHaveCSS can remain unchanged.

A couple questions:

  1. I did not add any usage related to non-JS APIs. Is that okay? Given the new CSSProperties parameter type, I wasn't sure how to translate them to different languages.

  2. I set since to v1.58. Is that right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I did not add any usage related to non-JS APIs. Is that okay? Given the new CSSProperties parameter type, I wasn't sure how to translate them to different languages.

Let's add langs: js to filter it out from other ports. I've added a comment.

  1. I set since to v1.58. Is that right?

Yes.

* since: v1.58
- `styles` <[React.CSSProperties]>

CSS properties object.

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

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) | 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) |
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').toLowerCase();
}

export function formatObject(value: any, indent = ' ', mode: 'multiline' | 'oneline' = 'multiline'): string {
if (typeof value === 'string')
return escapeWithQuotes(value, '\'');
Expand Down
50 changes: 44 additions & 6 deletions packages/playwright/src/matchers/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
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 };

Expand Down Expand Up @@ -308,17 +309,39 @@
}, expected, options);
}

export function toHaveCSS(this: ExpectMatcherState, locator: LocatorEx, name: string, expected: string | RegExp, options?: { timeout?: number }): Promise<MatcherResult<any, any>>;

Check failure on line 312 in packages/playwright/src/matchers/matchers.ts

View workflow job for this annotation

GitHub Actions / docs & lint

Cannot find name 'MatcherResult'.
export function toHaveCSS(this: ExpectMatcherState, locator: LocatorEx, styles: CSSProperties, options?: { timeout?: number }): Promise<MatcherResult<any, any>>;

Check failure on line 313 in packages/playwright/src/matchers/matchers.ts

View workflow job for this annotation

GitHub Actions / docs & lint

Cannot find name 'MatcherResult'.
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(
Expand Down Expand Up @@ -506,3 +529,18 @@
}
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)}`;

Check failure on line 542 in packages/playwright/src/matchers/matchers.ts

View workflow job for this annotation

GitHub Actions / docs & lint

Cannot find name 'toKebabCase'.
}

return toKebabCase(name);

Check failure on line 545 in packages/playwright/src/matchers/matchers.ts

View workflow job for this annotation

GitHub Actions / docs & lint

Cannot find name 'toKebabCase'.
}
15 changes: 15 additions & 0 deletions packages/playwright/types/test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -8471,6 +8472,20 @@ export type Expect<ExtendedMatchers = {}> = {
declare global {
export namespace PlaywrightTest {
export interface Matchers<R, T = unknown> {
/**
* 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<void>;
}
}
}
Expand Down
24 changes: 24 additions & 0 deletions tests/page/expect-misc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<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 React.CSSPProperties that are camelCase', 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('vendor React.CSSPProperties that are 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('custom React.CSSPProperties that are 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 React.CSSProperties);
});
});

test.describe('toHaveId', () => {
Expand Down
7 changes: 7 additions & 0 deletions utils/generate_types/overrides-test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -492,6 +493,12 @@ export type Expect<ExtendedMatchers = {}> = {
declare global {
export namespace PlaywrightTest {
export interface Matchers<R, T = unknown> {
/**
* 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<void>;
}
}
}
Expand Down
Loading