diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index 59618d0fbf..aef5639515 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -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 diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 82b5b0c81d..fbcc8e936f 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -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( + 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; diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 2992a41918..0d8ef8213c 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -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); } } diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index b3e0cb8595..23f63b83df 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -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, 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`. */ diff --git a/tests/page/expect-misc.spec.ts b/tests/page/expect-misc.spec.ts index f54c8271c3..2fb6d7e516 100644 --- a/tests/page/expect-misc.spec.ts +++ b/tests/page/expect-misc.spec.ts @@ -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('
'); + 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('
'); + 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('
'); + 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', () => {