Skip to content

Commit 750fc94

Browse files
authored
feat(expect): toHaveCSS with object notation (#38982)
1 parent 2307bf3 commit 750fc94

14 files changed

Lines changed: 379 additions & 242 deletions

File tree

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: 33 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,51 @@ 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+
1753+
## async method: LocatorAssertions.toHaveCSS#2
1754+
* since: v1.58
1755+
* langs: js
1756+
1757+
Ensures the [Locator] resolves to an element with the given computed CSS properties. Only the listed properties are checked.
1758+
1759+
**Usage**
1760+
1761+
```js
1762+
const locator = page.getByRole('button');
1763+
await expect(locator).toHaveCSS({
1764+
display: 'flex',
1765+
backgroundColor: 'rgb(255, 0, 0)'
1766+
});
1767+
```
1768+
1769+
### param: LocatorAssertions.toHaveCSS#2.styles
1770+
* since: v1.58
1771+
- `styles` <[Object]>
1772+
1773+
CSS properties object. See [CSSStyleProperties](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleProperties) for available properties.
1774+
1775+
### option: LocatorAssertions.toHaveCSS#2.timeout = %%-js-assertions-timeout-%%
1776+
* since: v1.58
1777+
1778+
17521779
## async method: LocatorAssertions.toHaveId
17531780
* since: v1.20
17541781
* langs:

docs/src/release-notes-js.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3196,7 +3196,7 @@ List of all new assertions:
31963196
- [`expect(locator).toHaveAttribute(name, value)`](./api/class-locatorassertions#locator-assertions-to-have-attribute)
31973197
- [`expect(locator).toHaveClass(expected)`](./api/class-locatorassertions#locator-assertions-to-have-class)
31983198
- [`expect(locator).toHaveCount(count)`](./api/class-locatorassertions#locator-assertions-to-have-count)
3199-
- [`expect(locator).toHaveCSS(name, value)`](./api/class-locatorassertions#locator-assertions-to-have-css)
3199+
- [`expect(locator).toHaveCSS(name, value)`](./api/class-locatorassertions#locator-assertions-to-have-css-1)
32003200
- [`expect(locator).toHaveId(id)`](./api/class-locatorassertions#locator-assertions-to-have-id)
32013201
- [`expect(locator).toHaveJSProperty(name, value)`](./api/class-locatorassertions#locator-assertions-to-have-js-property)
32023202
- [`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/injected/src/injectedScript.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1423,7 +1423,7 @@ export class InjectedScript {
14231423
// Element state / boolean values.
14241424
let result: ElementStateQueryResult | undefined;
14251425
if (expression === 'to.have.attribute') {
1426-
const hasAttribute = element.hasAttribute(options.expressionArg);
1426+
const hasAttribute = element.hasAttribute(options.expressionArg || '');
14271427
result = {
14281428
matches: hasAttribute,
14291429
received: hasAttribute ? 'attribute present' : 'attribute not present',
@@ -1487,7 +1487,7 @@ export class InjectedScript {
14871487
// JS property
14881488
if (expression === 'to.have.property') {
14891489
let target = element;
1490-
const properties = options.expressionArg.split('.');
1490+
const properties = (options.expressionArg || '').split('.');
14911491
for (let i = 0; i < properties.length - 1; i++) {
14921492
if (typeof target !== 'object' || !(properties[i] in target))
14931493
return { received: undefined, matches: false };
@@ -1498,6 +1498,26 @@ export class InjectedScript {
14981498
return { received, matches };
14991499
}
15001500
}
1501+
1502+
{
1503+
// Computed style object
1504+
if (expression === 'to.have.css.object') {
1505+
const expected = (options.expectedValue ?? {}) as Record<string, string>;
1506+
const received: Record<string, string> = {};
1507+
let matches = true;
1508+
const style = this.window.getComputedStyle(element);
1509+
for (const [prop, value] of Object.entries(expected)) {
1510+
let computed = style[prop as any];
1511+
if (typeof computed !== 'string')
1512+
computed = '';
1513+
if (computed !== value)
1514+
matches = false;
1515+
received[prop] = computed;
1516+
}
1517+
return { received, matches };
1518+
}
1519+
}
1520+
15011521
{
15021522
// Viewport intersection
15031523
if (expression === 'to.be.in.viewport') {
@@ -1534,7 +1554,7 @@ export class InjectedScript {
15341554
// Single text value.
15351555
let received: string | undefined;
15361556
if (expression === 'to.have.attribute.value') {
1537-
const value = element.getAttribute(options.expressionArg);
1557+
const value = element.getAttribute(options.expressionArg || '');
15381558
if (value === null)
15391559
return { received: null, matches: false };
15401560
received = value;
@@ -1546,7 +1566,7 @@ export class InjectedScript {
15461566
matches: new ExpectedTextMatcher(options.expectedText[0]).matchesClassList(this, element.classList, /* partial */ expression === 'to.contain.class'),
15471567
};
15481568
} else if (expression === 'to.have.css') {
1549-
received = this.window.getComputedStyle(element).getPropertyValue(options.expressionArg);
1569+
received = this.window.getComputedStyle(element).getPropertyValue(options.expressionArg || '');
15501570
} else if (expression === 'to.have.id') {
15511571
received = element.id;
15521572
} else if (expression === 'to.have.text') {

packages/playwright-core/src/protocol/validator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1921,7 +1921,7 @@ scheme.FrameWaitForSelectorResult = tObject({
19211921
scheme.FrameExpectParams = tObject({
19221922
selector: tOptional(tString),
19231923
expression: tString,
1924-
expressionArg: tOptional(tAny),
1924+
expressionArg: tOptional(tString),
19251925
expectedText: tOptional(tArray(tType('ExpectedTextValue'))),
19261926
expectedNumber: tOptional(tFloat),
19271927
expectedValue: tOptional(tType('SerializedArgument')),

packages/playwright-core/src/server/frames.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1480,8 +1480,11 @@ export class Frame extends SdkObject<FrameEventMap> {
14801480
lastIntermediateResult.received = received;
14811481
}
14821482
lastIntermediateResult.isSet = true;
1483-
if (!missingReceived && !Array.isArray(received))
1484-
progress.log(` unexpected value "${renderUnexpectedValue(options.expression, received)}"`);
1483+
if (!missingReceived) {
1484+
const rendered = renderUnexpectedValue(options.expression, received);
1485+
if (rendered !== undefined)
1486+
progress.log(` unexpected value "${rendered}"`);
1487+
}
14851488
}
14861489
return { matches, received };
14871490
}
@@ -1745,8 +1748,10 @@ function verifyLifecycle(name: string, waitUntil: types.LifecycleEvent): types.L
17451748
return waitUntil;
17461749
}
17471750

1748-
function renderUnexpectedValue(expression: string, received: any): string {
1751+
function renderUnexpectedValue(expression: string, received: any): string | undefined {
17491752
if (expression === 'to.match.aria')
1750-
return received ? received.raw : received;
1751-
return received;
1753+
received = received?.raw;
1754+
if (Array.isArray(received) || (!!received && typeof received === 'object'))
1755+
return;
1756+
return String(received);
17521757
}

packages/playwright/src/matchers/matchers.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -313,18 +313,30 @@ export function toHaveCount(
313313
}, expected, options);
314314
}
315315

316+
type ToHaveCSSOptions = { timeout?: number };
316317
export function toHaveCSS(this: ExpectMatcherStateInternal, locator: LocatorEx, name: string, expected: string | RegExp, options?: { timeout?: number }): Promise<MatcherResult<any, any>>;
318+
export function toHaveCSS(this: ExpectMatcherStateInternal, locator: LocatorEx, styles: Record<string, string>, options?: { timeout?: number }): Promise<MatcherResult<any, any>>;
317319
export function toHaveCSS(
318320
this: ExpectMatcherStateInternal,
319321
locator: LocatorEx,
320-
name: string,
321-
expected: string | RegExp,
322-
options?: { timeout?: number },
322+
arg1: string | Record<string, string>,
323+
arg2?: string | RegExp | ToHaveCSSOptions,
324+
arg3?: ToHaveCSSOptions,
323325
) {
324-
return toMatchText.call(this, 'toHaveCSS', locator, 'Locator', async (isNot, timeout) => {
325-
const expectedText = serializeExpectedTextValues([expected]);
326-
return await locator._expect('to.have.css', { expressionArg: name, expectedText, isNot, timeout });
327-
}, expected, options);
326+
if (typeof arg1 === 'string') {
327+
if (arg2 === undefined || !(isString(arg2) || isRegExp(arg2)))
328+
throw new Error(`toHaveCSS expected value must be a string or a regular expression`);
329+
return toMatchText.call(this, 'toHaveCSS', locator, 'Locator', async (isNot, timeout) => {
330+
const expectedText = serializeExpectedTextValues([arg2]);
331+
return await locator._expect('to.have.css', { expressionArg: arg1, expectedText, isNot, timeout });
332+
}, arg2, arg3);
333+
} else {
334+
if (typeof arg1 !== 'object' || !arg1)
335+
throw new Error(`toHaveCSS argument must be a string or an object`);
336+
return toEqual.call(this, 'toHaveCSS', locator, 'Locator', async (isNot, timeout) => {
337+
return await locator._expect('to.have.css.object', { isNot, expectedValue: arg1, timeout });
338+
}, arg1, arg2 as (ToHaveCSSOptions | undefined));
339+
}
328340
}
329341

330342
export function toHaveId(

0 commit comments

Comments
 (0)