Skip to content

Commit 7d89bc5

Browse files
committed
chore: add toHaveClass partial option
1 parent 36c55d8 commit 7d89bc5

File tree

5 files changed

+94
-28
lines changed

5 files changed

+94
-28
lines changed

docs/src/api/class-locatorassertions.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1431,7 +1431,7 @@ Attribute name.
14311431
* langs:
14321432
- alias-java: hasClass
14331433

1434-
Ensures the [Locator] points to an element with given CSS classes. When a string is provided, it must fully match the element's `class` attribute. To match individual classes or perform partial matches, use a regular expression:
1434+
Ensures the [Locator] points to an element with given CSS classes. When a string is provided, it must fully match the element's `class` attribute. To match individual classes or perform partial matches use [`option: LocatorAssertions.toHaveClass.partial`].
14351435

14361436
**Usage**
14371437

@@ -1442,34 +1442,34 @@ Ensures the [Locator] points to an element with given CSS classes. When a string
14421442
```js
14431443
const locator = page.locator('#component');
14441444
await expect(locator).toHaveClass('middle selected row');
1445-
await expect(locator).toHaveClass(/(^|\s)selected(\s|$)/);
1445+
await expect(locator).toHaveClass('selected', { partial: true });
14461446
```
14471447

14481448
```java
1449-
assertThat(page.locator("#component")).hasClass(Pattern.compile("(^|\\s)selected(\\s|$)"));
14501449
assertThat(page.locator("#component")).hasClass("middle selected row");
1450+
assertThat(page.locator("#component")).hasClass("selected", new LocatorAssertions.HasClassOptions().setPartial(true));
14511451
```
14521452

14531453
```python async
14541454
from playwright.async_api import expect
14551455

14561456
locator = page.locator("#component")
1457-
await expect(locator).to_have_class(re.compile(r"(^|\\s)selected(\\s|$)"))
14581457
await expect(locator).to_have_class("middle selected row")
1458+
await expect(locator).to_have_class("selected", partial=True)
14591459
```
14601460

14611461
```python sync
14621462
from playwright.sync_api import expect
14631463

14641464
locator = page.locator("#component")
1465-
expect(locator).to_have_class(re.compile(r"(^|\\s)selected(\\s|$)"))
14661465
expect(locator).to_have_class("middle selected row")
1466+
expect(locator).to_have_class("selected", partial=True)
14671467
```
14681468

14691469
```csharp
14701470
var locator = Page.Locator("#component");
1471-
await Expect(locator).ToHaveClassAsync(new Regex("(^|\\s)selected(\\s|$)"));
14721471
await Expect(locator).ToHaveClassAsync("middle selected row");
1472+
await Expect(locator).ToHaveClassAsync("selected", new() { Partial = true });
14731473
```
14741474

14751475
When an array is passed, the method asserts that the list of elements located matches the corresponding list of expected class values. Each element's class attribute is matched against the corresponding string or regular expression in the array:
@@ -1523,6 +1523,12 @@ Expected class or RegExp or a list of those.
15231523

15241524
Expected class or RegExp or a list of those.
15251525

1526+
### option: LocatorAssertions.toHaveClass.partial
1527+
* since: v1.52
1528+
- `partial` <[boolean]>
1529+
1530+
Whether to perform a partial match, defaults to `false`. In an exact match, which is the default, the `className` attribute must be exactly the same as the asserted value. In a partial match, all classes from the asserted value, separated by spaces, must be present in the [Element.classList](https://developer.mozilla.org/en-US/docs/Web/API/Element/classList) in any order. Partial match does not support a regular expression.
1531+
15261532
### option: LocatorAssertions.toHaveClass.timeout = %%-js-assertions-timeout-%%
15271533
* since: v1.18
15281534

packages/playwright-core/src/server/injected/injectedScript.ts

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616

1717
import { parseAriaSnapshot } from '@isomorphic/ariaSnapshot';
18+
import { assert } from '@isomorphic/assert';
1819

1920
import { generateAriaTree, getAllByAria, matchesAriaTree, renderAriaTree } from './ariaSnapshot';
2021
import { enclosingShadowRootOrDocument, isElementVisible, isInsideScope, parentElementOrShadowHost, setBrowserName } from './domUtils';
@@ -1398,7 +1399,10 @@ export class InjectedScript {
13981399
return { received: null, matches: false };
13991400
received = value;
14001401
} else if (expression === 'to.have.class') {
1401-
received = element.classList.toString();
1402+
return {
1403+
received: element.classList.toString(),
1404+
matches: new ExpectedTextMatcher(options.expectedText![0]).matchesClassList(element.classList, options.expressionArg.partial),
1405+
};
14021406
} else if (expression === 'to.have.css') {
14031407
received = this.window.getComputedStyle(element).getPropertyValue(options.expressionArg);
14041408
} else if (expression === 'to.have.id') {
@@ -1440,34 +1444,43 @@ export class InjectedScript {
14401444
const received = elements.length;
14411445
const matches = received === options.expectedNumber;
14421446
return { received, matches };
1447+
} else if (expression === 'to.have.class.array') {
1448+
const received = elements.map(e => e.classList);
1449+
if (!received.length)
1450+
return { received, matches: false };
1451+
return this._compareMatchersWithReceived(options.expectedText!, received, (matcher, classList) => matcher.matchesClassList(classList, options.expressionArg.partial));
14431452
}
14441453

1445-
// List of values.
1446-
let received: string[] | undefined;
1447-
if (expression === 'to.have.text.array' || expression === 'to.contain.text.array')
1448-
received = elements.map(e => options.useInnerText ? (e as HTMLElement).innerText : elementText(new Map(), e).full);
1449-
else if (expression === 'to.have.class.array')
1450-
received = elements.map(e => e.classList.toString());
1454+
assert(expression === 'to.have.text.array' || expression === 'to.contain.text.array', 'Unknown expect matcher: ' + expression);
14511455

1452-
if (received && options.expectedText) {
1456+
const received = elements.map(e => options.useInnerText ? (e as HTMLElement).innerText : elementText(new Map(), e).full);
1457+
if (options.expectedText) {
14531458
// "To match an array" is "to contain an array" + "equal length"
14541459
const lengthShouldMatch = expression !== 'to.contain.text.array';
14551460
const matchesLength = received.length === options.expectedText.length || !lengthShouldMatch;
14561461
if (!matchesLength)
14571462
return { received, matches: false };
14581463

1459-
// Each matcher should get a "received" that matches it, in order.
1460-
const matchers = options.expectedText.map(e => new ExpectedTextMatcher(e));
1461-
let mIndex = 0, rIndex = 0;
1462-
while (mIndex < matchers.length && rIndex < received.length) {
1463-
if (matchers[mIndex].matches(received[rIndex]))
1464-
++mIndex;
1465-
++rIndex;
1466-
}
1467-
return { received, matches: mIndex === matchers.length };
1464+
return this._compareMatchersWithReceived(options.expectedText, received, (matcher, text) => matcher.matches(text));
14681465
}
14691466
throw this.createStacklessError('Unknown expect matcher: ' + expression);
14701467
}
1468+
1469+
private _compareMatchersWithReceived<Received = any>(
1470+
expectedText: channels.ExpectedTextValue[],
1471+
received: Received[],
1472+
predicate: (foobar: ExpectedTextMatcher, received: Received) => boolean
1473+
): { matches: boolean, received?: any } {
1474+
// Each matcher should get a "received" that matches it, in order.
1475+
const matchers = expectedText.map(e => new ExpectedTextMatcher(e));
1476+
let mIndex = 0, rIndex = 0;
1477+
while (mIndex < matchers.length && rIndex < received.length) {
1478+
if (predicate(matchers[mIndex], received[rIndex]))
1479+
++mIndex;
1480+
++rIndex;
1481+
}
1482+
return { received, matches: mIndex === matchers.length };
1483+
}
14711484
}
14721485

14731486
const autoClosingTags = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
@@ -1623,6 +1636,15 @@ class ExpectedTextMatcher {
16231636
return false;
16241637
}
16251638

1639+
matchesClassList(classList: DOMTokenList, partial: boolean): boolean {
1640+
if (partial) {
1641+
if (this._regex)
1642+
throw new Error('Partial matching does not support regular expressions. Please provide a string value.');
1643+
return this._string!.split(/\s+/g).filter(Boolean).every(className => classList.contains(className));
1644+
}
1645+
return this.matches(classList.toString());
1646+
}
1647+
16261648
private normalize(s: string | undefined): string | undefined {
16271649
if (!s)
16281650
return s;

packages/playwright/src/matchers/matchers.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,17 +252,18 @@ export function toHaveClass(
252252
this: ExpectMatcherState,
253253
locator: LocatorEx,
254254
expected: string | RegExp | (string | RegExp)[],
255-
options?: { timeout?: number },
255+
options?: { timeout?: number, partial: boolean },
256256
) {
257+
const partial = options?.partial;
257258
if (Array.isArray(expected)) {
258259
return toEqual.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout) => {
259260
const expectedText = serializeExpectedTextValues(expected);
260-
return await locator._expect('to.have.class.array', { expectedText, isNot, timeout });
261+
return await locator._expect('to.have.class.array', { expectedText, expressionArg: { partial }, isNot, timeout });
261262
}, expected, options);
262263
} else {
263264
return toMatchText.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout) => {
264265
const expectedText = serializeExpectedTextValues([expected]);
265-
return await locator._expect('to.have.class', { expectedText, isNot, timeout });
266+
return await locator._expect('to.have.class', { expectedText, expressionArg: { partial }, isNot, timeout });
266267
}, expected, options);
267268
}
268269
}

packages/playwright/types/test.d.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8397,7 +8397,8 @@ interface LocatorAssertions {
83978397
/**
83988398
* Ensures the [Locator](https://playwright.dev/docs/api/class-locator) points to an element with given CSS classes.
83998399
* When a string is provided, it must fully match the element's `class` attribute. To match individual classes or
8400-
* perform partial matches, use a regular expression:
8400+
* perform partial matches use
8401+
* [`partial`](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-class-option-partial).
84018402
*
84028403
* **Usage**
84038404
*
@@ -8408,7 +8409,7 @@ interface LocatorAssertions {
84088409
* ```js
84098410
* const locator = page.locator('#component');
84108411
* await expect(locator).toHaveClass('middle selected row');
8411-
* await expect(locator).toHaveClass(/(^|\s)selected(\s|$)/);
8412+
* await expect(locator).toHaveClass('selected', { partial: true });
84128413
* ```
84138414
*
84148415
* When an array is passed, the method asserts that the list of elements located matches the corresponding list of
@@ -8424,6 +8425,15 @@ interface LocatorAssertions {
84248425
* @param options
84258426
*/
84268427
toHaveClass(expected: string|RegExp|ReadonlyArray<string|RegExp>, options?: {
8428+
/**
8429+
* Whether to perform a partial match, defaults to `false`. In an exact match, which is the default, the `className`
8430+
* attribute must be exactly the same as the asserted value. In a partial match, all classes from the asserted value,
8431+
* separated by spaces, must be present in the
8432+
* [Element.classList](https://developer.mozilla.org/en-US/docs/Web/API/Element/classList) in any order. Partial match
8433+
* does not support a regular expression.
8434+
*/
8435+
partial?: boolean;
8436+
84278437
/**
84288438
* Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`.
84298439
*/

tests/page/expect-misc.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,33 @@ test.describe('toHaveClass', () => {
220220
const error = await expect(locator).toHaveClass(['foo', 'bar', /[a-z]az/], { timeout: 1000 }).catch(e => e);
221221
expect(error.message).toContain('expect.toHaveClass with timeout 1000ms');
222222
});
223+
224+
test('allow matching partial class names', async ({ page }) => {
225+
await page.setContent('<div class="foo bar"></div>');
226+
const locator = page.locator('div');
227+
await expect(locator).toHaveClass('foo', { partial: true });
228+
await expect(locator).toHaveClass('bar', { partial: true });
229+
await expect(
230+
expect(locator).toHaveClass(/f.o/, { partial: true })
231+
).rejects.toThrow('Partial matching does not support regular expressions. Please provide a string value.');
232+
await expect(locator).not.toHaveClass('foo');
233+
await expect(locator).not.toHaveClass('foo', { partial: false });
234+
await expect(locator).toHaveClass(' bar foo ', { partial: true });
235+
await expect(locator).not.toHaveClass(' baz foo ', { partial: true }); // Strip whitespace and match individual classes
236+
});
237+
238+
test('allow matching partial class names with array', async ({ page }) => {
239+
await page.setContent('<div class="aaa"></div><div class="bbb b2b"></div><div class="ccc"></div>');
240+
const locator = page.locator('div');
241+
await expect(locator).toHaveClass(['aaa', 'b2b', 'ccc'], { partial: true });
242+
await expect(locator).not.toHaveClass(['aaa', 'b2b', 'ccc']);
243+
await expect(
244+
expect(locator).toHaveClass([/b2?ar/], { partial: true })
245+
).rejects.toThrow('Partial matching does not support regular expressions. Please provide a string value.');
246+
await expect(locator).not.toHaveClass(['aaa', 'b2b', 'ccc'], { partial: false });
247+
await expect(locator).not.toHaveClass(['not-there', 'b2b', 'ccc'], { partial: true }); // Class not there
248+
await expect(locator).not.toHaveClass(['aaa', 'b2b'], { partial: false }); // Length mismatch
249+
});
223250
});
224251

225252
test.describe('toHaveTitle', () => {

0 commit comments

Comments
 (0)