Skip to content

Commit 0a73c70

Browse files
committed
chore: add toHaveClass partial option
1 parent 36c55d8 commit 0a73c70

File tree

5 files changed

+111
-34
lines changed

5 files changed

+111
-34
lines changed

docs/src/api/class-locatorassertions.md

+12-6
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

+56-23
Original file line numberDiff line numberDiff line change
@@ -1398,7 +1398,10 @@ export class InjectedScript {
13981398
return { received: null, matches: false };
13991399
received = value;
14001400
} else if (expression === 'to.have.class') {
1401-
received = element.classList.toString();
1401+
return {
1402+
received: element.classList.toString(),
1403+
matches: new ExpectedTextMatcher(options.expectedText![0]).matchesClassList(element.classList, options.expressionArg.partial),
1404+
};
14021405
} else if (expression === 'to.have.css') {
14031406
received = this.window.getComputedStyle(element).getPropertyValue(options.expressionArg);
14041407
} else if (expression === 'to.have.id') {
@@ -1442,31 +1445,52 @@ export class InjectedScript {
14421445
return { received, matches };
14431446
}
14441447

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());
1451-
1452-
if (received && options.expectedText) {
1453-
// "To match an array" is "to contain an array" + "equal length"
1454-
const lengthShouldMatch = expression !== 'to.contain.text.array';
1455-
const matchesLength = received.length === options.expectedText.length || !lengthShouldMatch;
1456-
if (!matchesLength)
1448+
// Following matchers depend all on ExpectedTextValue.
1449+
if (!options.expectedText)
1450+
throw this.createStacklessError('Expected text is not provided for ' + expression);
1451+
1452+
if (expression === 'to.have.class.array') {
1453+
const receivedClassLists = elements.map(e => e.classList);
1454+
const received = receivedClassLists.map(String);
1455+
if (receivedClassLists.length !== options.expectedText.length)
14571456
return { received, matches: false };
1457+
const matches = this._matchSequentially(options.expectedText, receivedClassLists, (matcher, r) =>
1458+
matcher.matchesClassList(r, options.expressionArg.partial)
1459+
);
1460+
return {
1461+
received: received,
1462+
matches,
1463+
};
1464+
}
14581465

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 };
1466+
if (!['to.contain.text.array', 'to.have.text.array'].includes(expression))
1467+
throw this.createStacklessError('Unknown expect matcher: ' + expression);
1468+
1469+
const received = elements.map(e => options.useInnerText ? (e as HTMLElement).innerText : elementText(new Map(), e).full);
1470+
// "To match an array" is "to contain an array" + "equal length"
1471+
const lengthShouldMatch = expression !== 'to.contain.text.array';
1472+
const matchesLength = received.length === options.expectedText.length || !lengthShouldMatch;
1473+
if (!matchesLength)
1474+
return { received, matches: false };
1475+
1476+
const matches = this._matchSequentially(options.expectedText, received, (matcher, r) => matcher.matches(r));
1477+
return { received, matches };
1478+
}
1479+
1480+
private _matchSequentially<T>(
1481+
expectedText: channels.ExpectedTextValue[],
1482+
received: T[],
1483+
matchFn: (matcher: ExpectedTextMatcher, received: T) => boolean
1484+
): boolean {
1485+
const matchers = expectedText.map(e => new ExpectedTextMatcher(e));
1486+
let mIndex = 0;
1487+
let rIndex = 0;
1488+
while (mIndex < matchers.length && rIndex < received.length) {
1489+
if (matchFn(matchers[mIndex], received[rIndex]))
1490+
++mIndex;
1491+
++rIndex;
14681492
}
1469-
throw this.createStacklessError('Unknown expect matcher: ' + expression);
1493+
return mIndex === matchers.length;
14701494
}
14711495
}
14721496

@@ -1623,6 +1647,15 @@ class ExpectedTextMatcher {
16231647
return false;
16241648
}
16251649

1650+
matchesClassList(classList: DOMTokenList, partial: boolean): boolean {
1651+
if (partial) {
1652+
if (this._regex)
1653+
throw new Error('Partial matching does not support regular expressions. Please provide a string value.');
1654+
return this._string!.split(/\s+/g).filter(Boolean).every(className => classList.contains(className));
1655+
}
1656+
return this.matches(classList.toString());
1657+
}
1658+
16261659
private normalize(s: string | undefined): string | undefined {
16271660
if (!s)
16281661
return s;

packages/playwright/src/matchers/matchers.ts

+4-3
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

+12-2
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

+27
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)