chore: add toHaveClass partial option (#35229)

This commit is contained in:
Max Schmitt 2025-03-20 21:20:50 +01:00 committed by GitHub
parent 7cada0322a
commit fbffb8152f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 123 additions and 34 deletions

View File

@ -1431,7 +1431,7 @@ Attribute name.
* langs:
- alias-java: hasClass
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:
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`].
**Usage**
@ -1442,34 +1442,38 @@ Ensures the [Locator] points to an element with given CSS classes. When a string
```js
const locator = page.locator('#component');
await expect(locator).toHaveClass('middle selected row');
await expect(locator).toHaveClass(/(^|\s)selected(\s|$)/);
await expect(locator).toHaveClass('selected', { partial: true });
await expect(locator).toHaveClass('middle row', { partial: true });
```
```java
assertThat(page.locator("#component")).hasClass(Pattern.compile("(^|\\s)selected(\\s|$)"));
assertThat(page.locator("#component")).hasClass("middle selected row");
assertThat(page.locator("#component")).hasClass("selected", new LocatorAssertions.HasClassOptions().setPartial(true));
assertThat(page.locator("#component")).hasClass("middle row", new LocatorAssertions.HasClassOptions().setPartial(true));
```
```python async
from playwright.async_api import expect
locator = page.locator("#component")
await expect(locator).to_have_class(re.compile(r"(^|\\s)selected(\\s|$)"))
await expect(locator).to_have_class("middle selected row")
await expect(locator).to_have_class("middle row", partial=True)
```
```python sync
from playwright.sync_api import expect
locator = page.locator("#component")
expect(locator).to_have_class(re.compile(r"(^|\\s)selected(\\s|$)"))
expect(locator).to_have_class("middle selected row")
expect(locator).to_have_class("selected", partial=True)
expect(locator).to_have_class("middle row", partial=True)
```
```csharp
var locator = Page.Locator("#component");
await Expect(locator).ToHaveClassAsync(new Regex("(^|\\s)selected(\\s|$)"));
await Expect(locator).ToHaveClassAsync("middle selected row");
await Expect(locator).ToHaveClassAsync("selected", new() { Partial = true });
await Expect(locator).ToHaveClassAsync("middle row", new() { Partial = true });
```
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.
Expected class or RegExp or a list of those.
### option: LocatorAssertions.toHaveClass.partial
* since: v1.52
- `partial` <[boolean]>
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.
### option: LocatorAssertions.toHaveClass.timeout = %%-js-assertions-timeout-%%
* since: v1.18

View File

@ -1398,7 +1398,12 @@ export class InjectedScript {
return { received: null, matches: false };
received = value;
} else if (expression === 'to.have.class') {
received = element.classList.toString();
if (!options.expectedText)
throw this.createStacklessError('Expected text is not provided for ' + expression);
return {
received: element.classList.toString(),
matches: new ExpectedTextMatcher(options.expectedText[0]).matchesClassList(this, element.classList, options.expressionArg.partial),
};
} else if (expression === 'to.have.css') {
received = this.window.getComputedStyle(element).getPropertyValue(options.expressionArg);
} else if (expression === 'to.have.id') {
@ -1442,31 +1447,52 @@ export class InjectedScript {
return { received, matches };
}
// List of values.
let received: string[] | undefined;
if (expression === 'to.have.text.array' || expression === 'to.contain.text.array')
received = elements.map(e => options.useInnerText ? (e as HTMLElement).innerText : elementText(new Map(), e).full);
else if (expression === 'to.have.class.array')
received = elements.map(e => e.classList.toString());
// Following matchers depend all on ExpectedTextValue.
if (!options.expectedText)
throw this.createStacklessError('Expected text is not provided for ' + expression);
if (received && options.expectedText) {
// "To match an array" is "to contain an array" + "equal length"
const lengthShouldMatch = expression !== 'to.contain.text.array';
const matchesLength = received.length === options.expectedText.length || !lengthShouldMatch;
if (!matchesLength)
if (expression === 'to.have.class.array') {
const receivedClassLists = elements.map(e => e.classList);
const received = receivedClassLists.map(String);
if (receivedClassLists.length !== options.expectedText.length)
return { received, matches: false };
// Each matcher should get a "received" that matches it, in order.
const matchers = options.expectedText.map(e => new ExpectedTextMatcher(e));
let mIndex = 0, rIndex = 0;
while (mIndex < matchers.length && rIndex < received.length) {
if (matchers[mIndex].matches(received[rIndex]))
++mIndex;
++rIndex;
}
return { received, matches: mIndex === matchers.length };
const matches = this._matchSequentially(options.expectedText, receivedClassLists, (matcher, r) =>
matcher.matchesClassList(this, r, options.expressionArg.partial)
);
return {
received: received,
matches,
};
}
throw this.createStacklessError('Unknown expect matcher: ' + expression);
if (!['to.contain.text.array', 'to.have.text.array'].includes(expression))
throw this.createStacklessError('Unknown expect matcher: ' + expression);
const received = elements.map(e => options.useInnerText ? (e as HTMLElement).innerText : elementText(new Map(), e).full);
// "To match an array" is "to contain an array" + "equal length"
const lengthShouldMatch = expression !== 'to.contain.text.array';
const matchesLength = received.length === options.expectedText.length || !lengthShouldMatch;
if (!matchesLength)
return { received, matches: false };
const matches = this._matchSequentially(options.expectedText, received, (matcher, r) => matcher.matches(r));
return { received, matches };
}
private _matchSequentially<T>(
expectedText: channels.ExpectedTextValue[],
received: T[],
matchFn: (matcher: ExpectedTextMatcher, received: T) => boolean
): boolean {
const matchers = expectedText.map(e => new ExpectedTextMatcher(e));
let mIndex = 0;
let rIndex = 0;
while (mIndex < matchers.length && rIndex < received.length) {
if (matchFn(matchers[mIndex], received[rIndex]))
++mIndex;
++rIndex;
}
return mIndex === matchers.length;
}
}
@ -1623,6 +1649,15 @@ class ExpectedTextMatcher {
return false;
}
matchesClassList(injectedScript: InjectedScript, classList: DOMTokenList, partial: boolean): boolean {
if (partial) {
if (this._regex)
throw injectedScript.createStacklessError('Partial matching does not support regular expressions. Please provide a string value.');
return this._string!.split(/\s+/g).filter(Boolean).every(className => classList.contains(className));
}
return this.matches(classList.toString());
}
private normalize(s: string | undefined): string | undefined {
if (!s)
return s;

View File

@ -252,17 +252,18 @@ export function toHaveClass(
this: ExpectMatcherState,
locator: LocatorEx,
expected: string | RegExp | (string | RegExp)[],
options?: { timeout?: number },
options?: { timeout?: number, partial: boolean },
) {
const partial = options?.partial;
if (Array.isArray(expected)) {
return toEqual.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout) => {
const expectedText = serializeExpectedTextValues(expected);
return await locator._expect('to.have.class.array', { expectedText, isNot, timeout });
return await locator._expect('to.have.class.array', { expectedText, expressionArg: { partial }, isNot, timeout });
}, expected, options);
} else {
return toMatchText.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout) => {
const expectedText = serializeExpectedTextValues([expected]);
return await locator._expect('to.have.class', { expectedText, isNot, timeout });
return await locator._expect('to.have.class', { expectedText, expressionArg: { partial }, isNot, timeout });
}, expected, options);
}
}

View File

@ -8397,7 +8397,8 @@ interface LocatorAssertions {
/**
* Ensures the [Locator](https://playwright.dev/docs/api/class-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:
* perform partial matches use
* [`partial`](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-class-option-partial).
*
* **Usage**
*
@ -8408,7 +8409,8 @@ interface LocatorAssertions {
* ```js
* const locator = page.locator('#component');
* await expect(locator).toHaveClass('middle selected row');
* await expect(locator).toHaveClass(/(^|\s)selected(\s|$)/);
* await expect(locator).toHaveClass('selected', { partial: true });
* await expect(locator).toHaveClass('middle row', { partial: true });
* ```
*
* 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 {
* @param options
*/
toHaveClass(expected: string|RegExp|ReadonlyArray<string|RegExp>, options?: {
/**
* 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.
*/
partial?: boolean;
/**
* Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`.
*/

View File

@ -220,6 +220,38 @@ test.describe('toHaveClass', () => {
const error = await expect(locator).toHaveClass(['foo', 'bar', /[a-z]az/], { timeout: 1000 }).catch(e => e);
expect(error.message).toContain('expect.toHaveClass with timeout 1000ms');
});
test('allow matching partial class names', async ({ page }) => {
await page.setContent('<div class="foo bar"></div>');
const locator = page.locator('div');
await expect(locator).toHaveClass('foo', { partial: true });
await expect(locator).toHaveClass('bar', { partial: true });
await expect(
expect(locator).toHaveClass(/f.o/, { partial: true })
).rejects.toThrow('Partial matching does not support regular expressions. Please provide a string value.');
await expect(locator).not.toHaveClass('foo');
await expect(locator).not.toHaveClass('foo', { partial: false });
await expect(locator).toHaveClass(' bar foo ', { partial: true });
await expect(locator).not.toHaveClass('does-not-exist', { partial: true });
await expect(locator).not.toHaveClass(' baz foo ', { partial: true }); // Strip whitespace and match individual classes
await page.setContent('<div class="foo bar baz"></div>');
await expect(locator).toHaveClass('foo bar', { partial: true });
await expect(locator).toHaveClass('', { partial: true });
});
test('allow matching partial class names with array', async ({ page }) => {
await page.setContent('<div class="aaa"></div><div class="bbb b2b"></div><div class="ccc"></div>');
const locator = page.locator('div');
await expect(locator).toHaveClass(['aaa', 'b2b', 'ccc'], { partial: true });
await expect(locator).not.toHaveClass(['aaa', 'b2b', 'ccc']);
await expect(
expect(locator).toHaveClass([/b2?ar/, /b2?ar/, /b2?ar/], { partial: true })
).rejects.toThrow('Partial matching does not support regular expressions. Please provide a string value.');
await expect(locator).not.toHaveClass(['aaa', 'b2b', 'ccc'], { partial: false });
await expect(locator).not.toHaveClass(['not-there', 'b2b', 'ccc'], { partial: true }); // Class not there
await expect(locator).not.toHaveClass(['aaa', 'b2b'], { partial: false }); // Length mismatch
});
});
test.describe('toHaveTitle', () => {