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: * langs:
- alias-java: hasClass - 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** **Usage**
@ -1442,34 +1442,38 @@ Ensures the [Locator] points to an element with given CSS classes. When a string
```js ```js
const locator = page.locator('#component'); const locator = page.locator('#component');
await expect(locator).toHaveClass('middle selected row'); 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 ```java
assertThat(page.locator("#component")).hasClass(Pattern.compile("(^|\\s)selected(\\s|$)"));
assertThat(page.locator("#component")).hasClass("middle selected row"); 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 ```python async
from playwright.async_api import expect from playwright.async_api import expect
locator = page.locator("#component") 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 selected row")
await expect(locator).to_have_class("middle row", partial=True)
``` ```
```python sync ```python sync
from playwright.sync_api import expect from playwright.sync_api import expect
locator = page.locator("#component") 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("middle selected row")
expect(locator).to_have_class("selected", partial=True)
expect(locator).to_have_class("middle row", partial=True)
``` ```
```csharp ```csharp
var locator = Page.Locator("#component"); 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("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: 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. 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-%% ### option: LocatorAssertions.toHaveClass.timeout = %%-js-assertions-timeout-%%
* since: v1.18 * since: v1.18

View File

@ -1398,7 +1398,12 @@ export class InjectedScript {
return { received: null, matches: false }; return { received: null, matches: false };
received = value; received = value;
} else if (expression === 'to.have.class') { } 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') { } else if (expression === 'to.have.css') {
received = this.window.getComputedStyle(element).getPropertyValue(options.expressionArg); received = this.window.getComputedStyle(element).getPropertyValue(options.expressionArg);
} else if (expression === 'to.have.id') { } else if (expression === 'to.have.id') {
@ -1442,31 +1447,52 @@ export class InjectedScript {
return { received, matches }; return { received, matches };
} }
// List of values. // Following matchers depend all on ExpectedTextValue.
let received: string[] | undefined; if (!options.expectedText)
if (expression === 'to.have.text.array' || expression === 'to.contain.text.array') throw this.createStacklessError('Expected text is not provided for ' + expression);
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());
if (received && options.expectedText) { 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 };
const matches = this._matchSequentially(options.expectedText, receivedClassLists, (matcher, r) =>
matcher.matchesClassList(this, r, options.expressionArg.partial)
);
return {
received: received,
matches,
};
}
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" // "To match an array" is "to contain an array" + "equal length"
const lengthShouldMatch = expression !== 'to.contain.text.array'; const lengthShouldMatch = expression !== 'to.contain.text.array';
const matchesLength = received.length === options.expectedText.length || !lengthShouldMatch; const matchesLength = received.length === options.expectedText.length || !lengthShouldMatch;
if (!matchesLength) if (!matchesLength)
return { received, matches: false }; return { received, matches: false };
// Each matcher should get a "received" that matches it, in order. const matches = this._matchSequentially(options.expectedText, received, (matcher, r) => matcher.matches(r));
const matchers = options.expectedText.map(e => new ExpectedTextMatcher(e)); return { received, matches };
let mIndex = 0, rIndex = 0; }
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) { while (mIndex < matchers.length && rIndex < received.length) {
if (matchers[mIndex].matches(received[rIndex])) if (matchFn(matchers[mIndex], received[rIndex]))
++mIndex; ++mIndex;
++rIndex; ++rIndex;
} }
return { received, matches: mIndex === matchers.length }; return mIndex === matchers.length;
}
throw this.createStacklessError('Unknown expect matcher: ' + expression);
} }
} }
@ -1623,6 +1649,15 @@ class ExpectedTextMatcher {
return false; 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 { private normalize(s: string | undefined): string | undefined {
if (!s) if (!s)
return s; return s;

View File

@ -252,17 +252,18 @@ export function toHaveClass(
this: ExpectMatcherState, this: ExpectMatcherState,
locator: LocatorEx, locator: LocatorEx,
expected: string | RegExp | (string | RegExp)[], expected: string | RegExp | (string | RegExp)[],
options?: { timeout?: number }, options?: { timeout?: number, partial: boolean },
) { ) {
const partial = options?.partial;
if (Array.isArray(expected)) { if (Array.isArray(expected)) {
return toEqual.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout) => { return toEqual.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout) => {
const expectedText = serializeExpectedTextValues(expected); 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); }, expected, options);
} else { } else {
return toMatchText.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout) => { return toMatchText.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout) => {
const expectedText = serializeExpectedTextValues([expected]); 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); }, 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. * 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 * 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** * **Usage**
* *
@ -8408,7 +8409,8 @@ interface LocatorAssertions {
* ```js * ```js
* const locator = page.locator('#component'); * const locator = page.locator('#component');
* await expect(locator).toHaveClass('middle selected row'); * 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 * 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 * @param options
*/ */
toHaveClass(expected: string|RegExp|ReadonlyArray<string|RegExp>, 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`. * 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); 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'); 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', () => { test.describe('toHaveTitle', () => {