diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index aef5639515fb2..78ed036c0fab3 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -197,6 +197,21 @@ The opposite of [`method: LocatorAssertions.toBeVisible`]. ### option: LocatorAssertions.NotToBeVisible.timeout = %%-csharp-java-python-assertions-timeout-%% * since: v1.18 +## async method: LocatorAssertions.NotToContainClass +* since: v1.52 +* langs: python + +The opposite of [`method: LocatorAssertions.toContainClass`]. + +### param: LocatorAssertions.NotToContainClass.expected +* since: v1.52 +- `expected` <[string]|[Array]<[string]>> + +Expected class or RegExp or a list of those. + +### option: LocatorAssertions.NotToContainClass.timeout = %%-csharp-java-python-assertions-timeout-%% +* since: v1.52 + ## async method: LocatorAssertions.NotToContainText * since: v1.20 * langs: python @@ -1018,6 +1033,107 @@ await Expect( ### option: LocatorAssertions.toBeVisible.timeout = %%-csharp-java-python-assertions-timeout-%% * since: v1.18 +## async method: LocatorAssertions.toContainClass +* since: v1.52 +* langs: + - alias-java: containsClass + +Ensures the [Locator] points to an element with given CSS classes. 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. + +**Usage** + +```html +
+``` + +```js +const locator = page.locator('#component'); +await expect(locator).toContainClass('middle selected row'); +await expect(locator).toContainClass('selected'); +await expect(locator).toContainClass('row middle'); +``` + +```java +assertThat(page.locator("#component")).containsClass("middle selected row"); +assertThat(page.locator("#component")).containsClass("selected"); +assertThat(page.locator("#component")).containsClass("row middle"); +``` + +```python async +from playwright.async_api import expect + +locator = page.locator("#component") +await expect(locator).to_contain_class("middle selected row") +await expect(locator).to_contain_class("selected") +await expect(locator).to_contain_class("row middle") +``` + +```python sync +from playwright.sync_api import expect + +locator = page.locator("#component") +expect(locator).to_contain_class("middle selected row") +expect(locator).to_contain_class("selected") +expect(locator).to_contain_class("row middle") +``` + +```csharp +var locator = Page.Locator("#component"); +await Expect(locator).ToContainClassAsync("middle selected row"); +await Expect(locator).ToContainClassAsync("selected"); +await Expect(locator).ToContainClassAsync("row middle"); +``` + +When an array is passed, the method asserts that the list of elements located matches the corresponding list of expected class lists. Each element's class attribute is matched against the corresponding class in the array: + +```html +
+
+
+
+ +``` + +```js +const locator = page.locator('list > .component'); +await expect(locator).toContainClass(['inactive', 'active', 'inactive']); +``` + +```java +assertThat(page.locator("list > .component")).containsClass(new String[] {"inactive", "active", "inactive"}); +``` + +```python async +from playwright.async_api import expect + +locator = page.locator("list > .component") +await expect(locator).to_contain_class(["inactive", "active", "inactive"]) +``` + +```python sync +from playwright.sync_api import expect + +locator = page.locator("list > .component") +await expect(locator).to_contain_class(["inactive", "active", "inactive"]) +``` + +```csharp +var locator = Page.Locator("list > .component"); +await Expect(locator).ToContainClassAsync(new string[]{"inactive", "active", "inactive"}); +``` + +### param: LocatorAssertions.toContainClass.expected +* since: v1.52 +- `expected` <[string]|[Array]<[string]>> + +A string containing expected class names, separated by spaces, or a list of such strings to assert multiple elements. + +### option: LocatorAssertions.toContainClass.timeout = %%-js-assertions-timeout-%% +* since: v1.52 + +### option: LocatorAssertions.toContainClass.timeout = %%-csharp-java-python-assertions-timeout-%% +* since: v1.52 + ## async method: LocatorAssertions.toContainText * since: v1.20 * langs: @@ -1431,7 +1547,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 [`option: LocatorAssertions.toHaveClass.partial`]. +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 use [`method: LocatorAssertions.toContainClass`]. **Usage** @@ -1442,14 +1558,12 @@ 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('selected', { partial: true }); -await expect(locator).toHaveClass('middle row', { partial: true }); +await expect(locator).toHaveClass(/(^|\s)selected(\s|$)/); ``` ```java 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)); +assertThat(page.locator("#component")).hasClass(Pattern.compile("(^|\\s)selected(\\s|$)")); ``` ```python async @@ -1457,7 +1571,7 @@ from playwright.async_api import expect locator = page.locator("#component") await expect(locator).to_have_class("middle selected row") -await expect(locator).to_have_class("middle row", partial=True) +await expect(locator).to_have_class(re.compile(r"(^|\\s)selected(\\s|$)")) ``` ```python sync @@ -1465,15 +1579,13 @@ from playwright.sync_api import expect locator = page.locator("#component") 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) +expect(locator).to_have_class(re.compile(r"(^|\\s)selected(\\s|$)")) ``` ```csharp var locator = Page.Locator("#component"); await Expect(locator).ToHaveClassAsync("middle selected row"); -await Expect(locator).ToHaveClassAsync("selected", new() { Partial = true }); -await Expect(locator).ToHaveClassAsync("middle row", new() { Partial = true }); +await Expect(locator).ToHaveClassAsync(new Regex("(^|\\s)selected(\\s|$)")); ``` 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: @@ -1527,12 +1639,6 @@ 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/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index ebb57cc23b908..0e5d07bcf2da8 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -1452,12 +1452,12 @@ export class InjectedScript { if (value === null) return { received: null, matches: false }; received = value; - } else if (expression === 'to.have.class') { + } else if (['to.have.class', 'to.contain.class'].includes(expression)) { if (!options.expectedText) throw this.createStacklessError('Expected text is not provided for ' + expression); return { received: element.classList.toString(), - matches: new ExpectedTextMatcher(this.builtins, options.expectedText[0]).matchesClassList(this, element.classList, options.expressionArg.partial), + matches: new ExpectedTextMatcher(this.builtins, options.expectedText[0]).matchesClassList(this, element.classList, /* partial */ expression === 'to.contain.class'), }; } else if (expression === 'to.have.css') { received = this.window.getComputedStyle(element).getPropertyValue(options.expressionArg); @@ -1506,13 +1506,13 @@ export class InjectedScript { if (!options.expectedText) throw this.createStacklessError('Expected text is not provided for ' + expression); - if (expression === 'to.have.class.array') { + if (['to.have.class.array', 'to.contain.class.array'].includes(expression)) { 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) + matcher.matchesClassList(this, r, /* partial */ expression === 'to.contain.class.array') ); return { received: received, diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index c050b6e8ddce0..565e2dff6a07b 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -34,6 +34,7 @@ import { toBeInViewport, toBeOK, toBeVisible, + toContainClass, toContainText, toHaveAccessibleDescription, toHaveAccessibleErrorMessage, @@ -257,6 +258,7 @@ const customAsyncMatchers = { toBeOK, toBeVisible, toContainText, + toContainClass, toHaveAccessibleDescription, toHaveAccessibleName, toHaveAccessibleErrorMessage, diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 0d8ef8213c2c3..342b559fa1498 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -252,18 +252,40 @@ export function toHaveClass( this: ExpectMatcherState, locator: LocatorEx, expected: string | RegExp | (string | RegExp)[], - options?: { timeout?: number, partial: boolean }, + options?: { timeout?: number }, ) { - 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, expressionArg: { partial }, isNot, timeout }); + return await locator._expect('to.have.class.array', { expectedText, 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, expressionArg: { partial }, isNot, timeout }); + return await locator._expect('to.have.class', { expectedText, isNot, timeout }); + }, expected, options); + } +} + +export function toContainClass( + this: ExpectMatcherState, + locator: LocatorEx, + expected: string | string[], + options?: { timeout?: number }, +) { + if (Array.isArray(expected)) { + if (expected.some(e => isRegExp(e))) + throw new Error(`"expected" argument in toContainClass cannot contain RegExp values`); + return toEqual.call(this, 'toContainClass', locator, 'Locator', async (isNot, timeout) => { + const expectedText = serializeExpectedTextValues(expected); + return await locator._expect('to.contain.class.array', { expectedText, isNot, timeout }); + }, expected, options); + } else { + if (isRegExp(expected)) + throw new Error(`"expected" argument in toContainClass cannot be a RegExp value`); + return toMatchText.call(this, 'toContainClass', locator, 'Locator', async (isNot, timeout) => { + const expectedText = serializeExpectedTextValues([expected]); + return await locator._expect('to.contain.class', { expectedText, isNot, timeout }); }, expected, options); } } diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index c349529c0ec61..37fd01e81b539 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -8200,6 +8200,51 @@ interface LocatorAssertions { visible?: boolean; }): Promise; + /** + * Ensures the [Locator](https://playwright.dev/docs/api/class-locator) points to an element with given CSS classes. + * 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. + * + * **Usage** + * + * ```html + *
+ * ``` + * + * ```js + * const locator = page.locator('#component'); + * await expect(locator).toContainClass('middle selected row'); + * await expect(locator).toContainClass('selected'); + * await expect(locator).toContainClass('row middle'); + * ``` + * + * When an array is passed, the method asserts that the list of elements located matches the corresponding list of + * expected class lists. Each element's class attribute is matched against the corresponding class in the array: + * + * ```html + *
+ *
+ *
+ *
+ * + * ``` + * + * ```js + * const locator = page.locator('list > .component'); + * await expect(locator).toContainClass(['inactive', 'active', 'inactive']); + * ``` + * + * @param expected A string containing expected class names, separated by spaces, or a list of such strings to assert multiple + * elements. + * @param options + */ + toContainClass(expected: string|ReadonlyArray, options?: { + /** + * Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. + */ + timeout?: number; + }): Promise; + /** * Ensures the [Locator](https://playwright.dev/docs/api/class-locator) points to an element that contains the given * text. All nested elements will be considered when computing the text content of the element. You can use regular @@ -8407,9 +8452,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 - * [`partial`](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-class-option-partial). + * When a string is provided, it must fully match the element's `class` attribute. To match individual classes use + * [expect(locator).toContainClass(expected[, options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-contain-class). * * **Usage** * @@ -8420,8 +8464,7 @@ interface LocatorAssertions { * ```js * const locator = page.locator('#component'); * await expect(locator).toHaveClass('middle selected row'); - * await expect(locator).toHaveClass('selected', { partial: true }); - * await expect(locator).toHaveClass('middle row', { partial: true }); + * await expect(locator).toHaveClass(/(^|\s)selected(\s|$)/); * ``` * * When an array is passed, the method asserts that the list of elements located matches the corresponding list of @@ -8437,15 +8480,6 @@ 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 2fb6d7e51633c..290d9f202c5c1 100644 --- a/tests/page/expect-misc.spec.ts +++ b/tests/page/expect-misc.spec.ts @@ -220,37 +220,46 @@ 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('
'); +test.describe('toContainClass', () => { + test('pass', 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 expect(locator).toContainClass(''); + await expect(locator).toContainClass('bar'); + await expect(locator).toContainClass('baz bar'); + await expect(locator).toContainClass(' bar foo '); + await expect(locator).not.toContainClass(' baz not-matching '); // Strip whitespace and match individual classes + expect(() => expect(locator).toContainClass(/foo|bar/ as any)).toThrow(/"expected\" argument in toContainClass cannot be a RegExp value/); + }); - await page.setContent('
'); - await expect(locator).toHaveClass('foo bar', { partial: true }); - await expect(locator).toHaveClass('', { partial: true }); + test('pass with SVGs', async ({ page }) => { + await page.setContent(``); + await expect(page.locator('svg')).toContainClass('c1'); + }); + + test('fail', async ({ page }) => { + await page.setContent('
'); + const locator = page.locator('div'); + const error = await expect(locator).toContainClass('does-not-exist', { timeout: 1000 }).catch(e => e); + expect(error.message).toContain('expect.toContainClass with timeout 1000ms'); }); - test('allow matching partial class names with array', async ({ page }) => { - await page.setContent('
'); + test('pass with array', async ({ page }) => { + await page.setContent('
'); + const locator = page.locator('div'); + await expect(locator).toContainClass(['foo', 'hello', 'baz']); + expect(() => expect(locator).toContainClass(['foo', 'hello', /baz/] as any)).toThrow(/"expected" argument in toContainClass cannot contain RegExp values/); + await expect(locator).not.toHaveClass(['not-there', 'hello', 'baz']); // Class not there + await expect(locator).not.toHaveClass(['foo', 'hello']); // Length mismatch + }); + + test('fail 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 + const error = await expect(locator).toContainClass(['foo', 'bar', 'baz'], { timeout: 1000 }).catch(e => e); + expect(error.message).toContain('expect.toContainClass with timeout 1000ms'); }); });