Skip to content

Commit fbffb81

Browse files
authored
chore: add toHaveClass partial option (#35229)
1 parent 7cada03 commit fbffb81

File tree

5 files changed

+123
-34
lines changed

5 files changed

+123
-34
lines changed

docs/src/api/class-locatorassertions.md

+16-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,38 @@ 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 });
1446+
await expect(locator).toHaveClass('middle row', { partial: true });
14461447
```
14471448

14481449
```java
1449-
assertThat(page.locator("#component")).hasClass(Pattern.compile("(^|\\s)selected(\\s|$)"));
14501450
assertThat(page.locator("#component")).hasClass("middle selected row");
1451+
assertThat(page.locator("#component")).hasClass("selected", new LocatorAssertions.HasClassOptions().setPartial(true));
1452+
assertThat(page.locator("#component")).hasClass("middle row", new LocatorAssertions.HasClassOptions().setPartial(true));
14511453
```
14521454

14531455
```python async
14541456
from playwright.async_api import expect
14551457

14561458
locator = page.locator("#component")
1457-
await expect(locator).to_have_class(re.compile(r"(^|\\s)selected(\\s|$)"))
14581459
await expect(locator).to_have_class("middle selected row")
1460+
await expect(locator).to_have_class("middle row", partial=True)
14591461
```
14601462

14611463
```python sync
14621464
from playwright.sync_api import expect
14631465

14641466
locator = page.locator("#component")
1465-
expect(locator).to_have_class(re.compile(r"(^|\\s)selected(\\s|$)"))
14661467
expect(locator).to_have_class("middle selected row")
1468+
expect(locator).to_have_class("selected", partial=True)
1469+
expect(locator).to_have_class("middle row", partial=True)
14671470
```
14681471

14691472
```csharp
14701473
var locator = Page.Locator("#component");
1471-
await Expect(locator).ToHaveClassAsync(new Regex("(^|\\s)selected(\\s|$)"));
14721474
await Expect(locator).ToHaveClassAsync("middle selected row");
1475+
await Expect(locator).ToHaveClassAsync("selected", new() { Partial = true });
1476+
await Expect(locator).ToHaveClassAsync("middle row", new() { Partial = true });
14731477
```
14741478

14751479
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 +1527,12 @@ Expected class or RegExp or a list of those.
15231527

15241528
Expected class or RegExp or a list of those.
15251529

1530+
### option: LocatorAssertions.toHaveClass.partial
1531+
* since: v1.52
1532+
- `partial` <[boolean]>
1533+
1534+
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.
1535+
15261536
### option: LocatorAssertions.toHaveClass.timeout = %%-js-assertions-timeout-%%
15271537
* since: v1.18
15281538

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

+58-23
Original file line numberDiff line numberDiff line change
@@ -1398,7 +1398,12 @@ 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+
if (!options.expectedText)
1402+
throw this.createStacklessError('Expected text is not provided for ' + expression);
1403+
return {
1404+
received: element.classList.toString(),
1405+
matches: new ExpectedTextMatcher(options.expectedText[0]).matchesClassList(this, element.classList, options.expressionArg.partial),
1406+
};
14021407
} else if (expression === 'to.have.css') {
14031408
received = this.window.getComputedStyle(element).getPropertyValue(options.expressionArg);
14041409
} else if (expression === 'to.have.id') {
@@ -1442,31 +1447,52 @@ export class InjectedScript {
14421447
return { received, matches };
14431448
}
14441449

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

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

@@ -1623,6 +1649,15 @@ class ExpectedTextMatcher {
16231649
return false;
16241650
}
16251651

1652+
matchesClassList(injectedScript: InjectedScript, classList: DOMTokenList, partial: boolean): boolean {
1653+
if (partial) {
1654+
if (this._regex)
1655+
throw injectedScript.createStacklessError('Partial matching does not support regular expressions. Please provide a string value.');
1656+
return this._string!.split(/\s+/g).filter(Boolean).every(className => classList.contains(className));
1657+
}
1658+
return this.matches(classList.toString());
1659+
}
1660+
16261661
private normalize(s: string | undefined): string | undefined {
16271662
if (!s)
16281663
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

+13-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,8 @@ 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 });
8413+
* await expect(locator).toHaveClass('middle row', { partial: true });
84128414
* ```
84138415
*
84148416
* When an array is passed, the method asserts that the list of elements located matches the corresponding list of
@@ -8424,6 +8426,15 @@ interface LocatorAssertions {
84248426
* @param options
84258427
*/
84268428
toHaveClass(expected: string|RegExp|ReadonlyArray<string|RegExp>, options?: {
8429+
/**
8430+
* Whether to perform a partial match, defaults to `false`. In an exact match, which is the default, the `className`
8431+
* attribute must be exactly the same as the asserted value. In a partial match, all classes from the asserted value,
8432+
* separated by spaces, must be present in the
8433+
* [Element.classList](https://developer.mozilla.org/en-US/docs/Web/API/Element/classList) in any order. Partial match
8434+
* does not support a regular expression.
8435+
*/
8436+
partial?: boolean;
8437+
84278438
/**
84288439
* Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`.
84298440
*/

tests/page/expect-misc.spec.ts

+32
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,38 @@ 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('does-not-exist', { partial: true });
236+
await expect(locator).not.toHaveClass(' baz foo ', { partial: true }); // Strip whitespace and match individual classes
237+
238+
await page.setContent('<div class="foo bar baz"></div>');
239+
await expect(locator).toHaveClass('foo bar', { partial: true });
240+
await expect(locator).toHaveClass('', { partial: true });
241+
});
242+
243+
test('allow matching partial class names with array', async ({ page }) => {
244+
await page.setContent('<div class="aaa"></div><div class="bbb b2b"></div><div class="ccc"></div>');
245+
const locator = page.locator('div');
246+
await expect(locator).toHaveClass(['aaa', 'b2b', 'ccc'], { partial: true });
247+
await expect(locator).not.toHaveClass(['aaa', 'b2b', 'ccc']);
248+
await expect(
249+
expect(locator).toHaveClass([/b2?ar/, /b2?ar/, /b2?ar/], { partial: true })
250+
).rejects.toThrow('Partial matching does not support regular expressions. Please provide a string value.');
251+
await expect(locator).not.toHaveClass(['aaa', 'b2b', 'ccc'], { partial: false });
252+
await expect(locator).not.toHaveClass(['not-there', 'b2b', 'ccc'], { partial: true }); // Class not there
253+
await expect(locator).not.toHaveClass(['aaa', 'b2b'], { partial: false }); // Length mismatch
254+
});
223255
});
224256

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

0 commit comments

Comments
 (0)