Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
24 changes: 24 additions & 0 deletions docs/src/api/class-locatorassertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1749,6 +1749,30 @@ CSS property value.
### option: LocatorAssertions.toHaveCSS.timeout = %%-csharp-java-python-assertions-timeout-%%
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 add #1 to the existing toHaveCSS declaration.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good. I had originally left the original implementation as the base method without the #1 from referring to how toHaveAttribute is implemented.

I updated the referring documentation to point to #1.

* since: v1.18

## async method: LocatorAssertions.toHaveCSS#2
* since: v1.20
Copy link
Member

Choose a reason for hiding this comment

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

let's add * langs: js to not have in language ports.


Ensures the [Locator] resolves to an element with the given computed CSS properties.

**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/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 / 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').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);
}
29 changes: 29 additions & 0 deletions packages/playwright/types/test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
import type { APIRequestContext, Browser, BrowserContext, BrowserContextOptions, Page, LaunchOptions, ViewportSize, Geolocation, HTTPCredentials, Locator, APIResponse, PageScreenshotOptions } from 'playwright-core';
export * from 'playwright-core';

// @ts-ignore this will be any if react is not installed
type ReactCSSProperties = import('react').CSSProperties;
type FallbackCSSProperties = { [name: string]: string | number | undefined };
export type CSSProperties = keyof ReactCSSProperties extends string ? ReactCSSProperties : FallbackCSSProperties;

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 +9155,30 @@ 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.
*
* **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
27 changes: 27 additions & 0 deletions tests/library/unit/string-utils.spec.ts
Copy link
Member

Choose a reason for hiding this comment

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

Remove this, we normally rely on the e2e tests with very rare exceptions for unit tests.

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { expect, test } from '@playwright/test';
import { toKebabCase } from '../../../packages/playwright-core/src/utils/isomorphic/stringUtils';

test.describe('toKebabCase', () => {
test('should convert to kebab case', () => {
expect(toKebabCase('')).toBe('');
expect(toKebabCase('display')).toBe('display');
expect(toKebabCase('backgroundColor')).toBe('background-color');
expect(toKebabCase('--customColor')).toBe('--custom-color');
});
});
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
5 changes: 5 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,11 @@
import type { APIRequestContext, Browser, BrowserContext, BrowserContextOptions, Page, LaunchOptions, ViewportSize, Geolocation, HTTPCredentials, Locator, APIResponse, PageScreenshotOptions } from 'playwright-core';
export * from 'playwright-core';

// @ts-ignore this will be any if react is not installed
type ReactCSSProperties = import('react').CSSProperties;
type FallbackCSSProperties = { [name: string]: string | number | undefined };
Copy link
Member

Choose a reason for hiding this comment

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

This should be something like never when react is missing to indicate the error. We don't want this signature to be used without react.

export type CSSProperties = keyof ReactCSSProperties extends string ? ReactCSSProperties : FallbackCSSProperties;

export type BlobReporterOptions = { outputDir?: string, fileName?: string };
export type ListReporterOptions = { printSteps?: boolean };
export type JUnitReporterOptions = { outputFile?: string, stripANSIControlSequences?: boolean, includeProjectInTestName?: boolean };
Expand Down
Loading