diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index 547e1c7f94..47a723c0ff 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -721,6 +721,99 @@ await Expect(locator).ToBeVisibleAsync(); ### option: LocatorAssertions.toBeVisible.timeout = %%-csharp-java-python-assertions-timeout-%% * since: v1.18 +## async method: LocatorAssertions.toContainClass +* since: v1.24 +* langs: + - alias-java: containsClass + +Ensures the [Locator] points to an element that contains the given CSS class (or multiple). +In contrast to [`method: LocatorAssertions.toHaveClass`] which requires that the [Locator] has exactly the provided classes, `toContainClass` verifies that the [Locator] has a subset (or all) of the given CSS classes. + +```html +
+
+
+
+``` + +```js +const locator = page.locator('#component'); +await expect(locator).toContainClass('bar baz'); // pass, both classes are on element +await expect(locator).toContainClass('ba'); // fail, element has no 'ba' class + +const itemLocator = page.locator('#component .item'); +await expect(itemLocator).toContainClass(['alice', 'bob']); // pass, first element has alice, second bob +await expect(itemLocator).toContainClass(['alice', 'bob carl']); // no carl class found on second item element +await expect(itemLocator).toContainClass(['alice', 'bob', 'foobar']); // we expect 3 elements with the item class, but there are only 2 +``` + +```java +Locator locator = page.locator("#component"); +assertThat(locator).containsClass("bar baz"); // pass, both classes are on element +assertThat(locator).containsClass("ba"); // fail, element has no 'ba' class + +Locator itemLocator = page.locator("#component .item"); +assertThat(itemLocator).toContainClass(new String[] {"alice", "bob"}); // pass, first element has alice, second bob +assertThat(itemLocator).toContainClass(new String[] {"alice", "bob carl"}); // no carl class found on second item element +assertThat(itemLocator).toContainClass(new String[] {"alice", "bob", "foobar"}); // we expect 3 elements with the item class, but there are only 2 +``` + +```python async +from playwright.async_api import expect + +locator = page.locator('#component') +expect(locator).to_contain_class('bar baz') # pass, both classes are on element +expect(locator).to_contain_class('ba') # fail, element has no 'ba' class + +item_locator = page.locator('#component .item') +expect(item_locator).to_contain_class(['alice', 'bob']) # pass, first element has alice, second bob +expect(item_locator).to_contain_class(['alice', 'bob carl']) # no carl class found on second item element +expect(item_locator).to_contain_class(['alice', 'bob', 'foobar']) # we expect 3 elements with the item class, but there are only 2 +``` + +```python sync +from playwright.sync_api import expect + +locator = page.locator('#component') +await expect(locator).to_contain_class('bar baz') # pass, both classes are on element +await expect(locator).to_contain_class('ba') # fail, element has no 'ba' class + +item_locator = page.locator('#component .item') +await expect(item_locator).to_contain_class(['alice', 'bob']) # pass, first element has alice, second bob +await expect(item_locator).to_contain_class(['alice', 'bob carl']) # no carl class found on second item element +await expect(item_locator).to_contain_class(['alice', 'bob', 'foobar']) # we expect 3 elements with the item class, but there are only 2 +``` + +```csharp +var locator = Page.Locator("#component"); +await Expect(locator).ToContainClassAsync("bar baz"); // pass, both classes are on element +await Expect(locator).ToContainClassAsync("ba"); // fail, element has no "ba" class + +var itemLocator = page.locator("#component .item"); +await Expect(itemLocator).ToContainClassAsync(new string[]{"alice", "bob"}); // pass, first element has alice, second bob +await Expect(itemLocator).ToContainClassAsync(new string[]{"alice", "bob carl"}); // no carl class found on second item element +await Expect(itemLocator).ToContainClassAsync(new string[]{"alice", "bob", "foobar"}); // we expect 3 elements with the item class, but there are only 2 +``` + +Note that locator must point to a single element when passing a string or to multiple elements when passing an array. + +### param: LocatorAssertions.toContainClass.expected +* since: v1.24 +- `expected` <[string]|[Array]<[string]>> + +Expected classnames, whitespace separated. When passing an array, the given classes must be present on the locator elements. + +### option: LocatorAssertions.toContainClass.ignoreCase +* since: v1.24 +- `ignoreCase` <[boolean]> + +Whether to perform case-insensitive match. + +### option: LocatorAssertions.toContainClass.timeout = %%-js-assertions-timeout-%% +* since: v1.24 +### option: LocatorAssertions.toContainClass.timeout = %%-csharp-java-python-assertions-timeout-%% +* since: v1.24 + ## async method: LocatorAssertions.toContainText * since: v1.20 * langs: @@ -883,15 +976,22 @@ Expected attribute value. * langs: - alias-java: hasClass -Ensures the [Locator] points to an element with given CSS class. +Ensures the [Locator] points to an element with given CSS classes. This needs to be a full match +or using a relaxed regular expression. For matching partial class names, use [`method: LocatorAssertions.toContainClass`]. + +```html +
+``` ```js const locator = page.locator('#component'); await expect(locator).toHaveClass(/selected/); +await expect(locator).toHaveClass('selected row'); ``` ```java assertThat(page.locator("#component")).hasClass(Pattern.compile("selected")); +assertThat(page.locator("#component")).hasClass("selected row"); ``` ```python async @@ -899,6 +999,7 @@ from playwright.async_api import expect locator = page.locator("#component") await expect(locator).to_have_class(re.compile(r"selected")) +await expect(locator).to_have_class("selected row") ``` ```python sync @@ -906,11 +1007,13 @@ from playwright.sync_api import expect locator = page.locator("#component") expect(locator).to_have_class(re.compile(r"selected")) +expect(locator).to_have_class("selected row") ``` ```csharp var locator = Page.Locator("#component"); await Expect(locator).ToHaveClassAsync(new Regex("selected")); +await Expect(locator).ToHaveClassAsync("selected row"); ``` Note that if array is passed as an expected value, entire lists of elements can be asserted: diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 5242981b1f..291bb4293b 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -79,7 +79,7 @@ export class InjectedScript { private _highlight: Highlight | undefined; readonly isUnderTest: boolean; - constructor(isUnderTest: boolean, stableRafCount: number, browserName: string, customEngines: { name: string, engine: SelectorEngine}[]) { + constructor(isUnderTest: boolean, stableRafCount: number, browserName: string, customEngines: { name: string, engine: SelectorEngine }[]) { this.isUnderTest = isUnderTest; this._evaluator = new SelectorEvaluatorImpl(new Map()); @@ -445,7 +445,7 @@ export class InjectedScript { element = element.closest('button, [role=button], [role=checkbox], [role=radio]') || element; if (behavior === 'follow-label') { if (!element.matches('input, textarea, button, select, [role=button], [role=checkbox], [role=radio]') && - !(element as any).isContentEditable) { + !(element as any).isContentEditable) { // Go up to the label that might be connected to the input/textarea. element = element.closest('label') || element; } @@ -1070,7 +1070,7 @@ export class InjectedScript { let received: string | undefined; if (expression === 'to.have.attribute') { received = element.getAttribute(options.expressionArg) || ''; - } else if (expression === 'to.have.class') { + } else if (expression === 'to.have.class' || expression === 'to.contain.class') { received = element.classList.toString(); } else if (expression === 'to.have.css') { received = window.getComputedStyle(element).getPropertyValue(options.expressionArg); @@ -1091,7 +1091,9 @@ export class InjectedScript { if (received !== undefined && options.expectedText) { const matcher = new ExpectedTextMatcher(options.expectedText[0]); - return { received, matches: matcher.matches(received) }; + return { received, matches: matcher.matches(received, { + toContainClass: expression === 'to.contain.class', + }) }; } } @@ -1111,7 +1113,7 @@ export class InjectedScript { 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 : e.textContent || ''); - else if (expression === 'to.have.class.array') + else if (expression === 'to.have.class.array' || expression === 'to.contain.class.array') received = elements.map(e => e.classList.toString()); if (received && options.expectedText) { @@ -1125,7 +1127,9 @@ export class InjectedScript { 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])) + if (matchers[mIndex].matches(received[rIndex], { + toContainClass: expression === 'to.contain.class.array', + })) ++mIndex; ++rIndex; } @@ -1151,11 +1155,11 @@ function oneLine(s: string): string { return s.replace(/\n/g, '↵').replace(/\t/g, '⇆'); } -const eventType = new Map([ +const eventType = new Map([ ['auxclick', 'mouse'], ['click', 'mouse'], ['dblclick', 'mouse'], - ['mousedown','mouse'], + ['mousedown', 'mouse'], ['mouseeenter', 'mouse'], ['mouseleave', 'mouse'], ['mousemove', 'mouse'], @@ -1258,7 +1262,9 @@ class ExpectedTextMatcher { } } - matches(text: string): boolean { + matches(text: string, { toContainClass }: { toContainClass?: boolean } = {}): boolean { + if (toContainClass) + return this.matchesClassList(text); if (!this._regex) text = this.normalize(text)!; if (this._string !== undefined) @@ -1270,6 +1276,18 @@ class ExpectedTextMatcher { return false; } + private matchesClassList(received: string): boolean { + const expected = this.normalizeClassList(this._string || ''); + if (expected.length === 0) + return false; + const normalizedReceived = this.normalizeClassList(received); + return expected.every(classListEntry => normalizedReceived.includes(classListEntry)); + } + + private normalizeClassList(classList: string): string[] { + return classList.trim().split(/\s+/g).map(c => this.normalize(c)).filter(c => c) as string[]; + } + private normalize(s: string | undefined): string | undefined { if (!s) return s; diff --git a/packages/playwright-test/src/expect.ts b/packages/playwright-test/src/expect.ts index eb17f3b7d5..d96af9f6a3 100644 --- a/packages/playwright-test/src/expect.ts +++ b/packages/playwright-test/src/expect.ts @@ -29,6 +29,7 @@ import { toContainText, toHaveAttribute, toHaveClass, + toContainClass, toHaveCount, toHaveCSS, toHaveId, @@ -134,6 +135,7 @@ const customMatchers = { toContainText, toHaveAttribute, toHaveClass, + toContainClass, toHaveCount, toHaveCSS, toHaveId, diff --git a/packages/playwright-test/src/matchers/matchers.ts b/packages/playwright-test/src/matchers/matchers.ts index c45f4b4ad8..01c8b9111b 100644 --- a/packages/playwright-test/src/matchers/matchers.ts +++ b/packages/playwright-test/src/matchers/matchers.ts @@ -164,6 +164,25 @@ export function toHaveClass( } } +export function toContainClass( + this: ReturnType, + locator: LocatorEx, + expected: string | string[], + options?: { timeout?: number, ignoreCase?: boolean }, +) { + if (Array.isArray(expected)) { + return toEqual.call(this, 'toContainClass', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + const expectedText = toExpectedTextValues(expected, { ignoreCase: options?.ignoreCase }); + return await locator._expect(customStackTrace, 'to.contain.class.array', { expectedText, isNot, timeout }); + }, expected, options); + } else { + return toMatchText.call(this, 'toContainClass', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + const expectedText = toExpectedTextValues([expected], { ignoreCase: options?.ignoreCase }); + return await locator._expect(customStackTrace, 'to.contain.class', { expectedText, isNot, timeout }); + }, expected, options); + } +} + export function toHaveCount( this: ReturnType, locator: LocatorEx, diff --git a/packages/playwright-test/types/test.d.ts b/packages/playwright-test/types/test.d.ts index 3f61e29531..8ad9a39fcc 100644 --- a/packages/playwright-test/types/test.d.ts +++ b/packages/playwright-test/types/test.d.ts @@ -3327,6 +3327,46 @@ interface LocatorAssertions { timeout?: number; }): Promise; + /** + * Ensures the [Locator] points to an element that contains the given CSS class (or multiple). In contrast to + * [locatorAssertions.toHaveClass(expected[, options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-class) + * which requires that the [Locator] has exactly the provided classes, `toContainClass` verifies that the [Locator] has a + * subset (or all) of the given CSS classes. + * + * ```html + *
+ *
+ *
+ *
+ * ``` + * + * ```js + * const locator = page.locator('#component'); + * await expect(locator).toContainClass('bar baz'); // pass, both classes are on element + * await expect(locator).toContainClass('ba'); // fail, element has no 'ba' class + * + * const itemLocator = page.locator('#component .item'); + * await expect(itemLocator).toContainClass(['alice', 'bob']); // pass, first element has alice, second bob + * await expect(itemLocator).toContainClass(['alice', 'bob carl']); // no carl class found on second item element + * await expect(itemLocator).toContainClass(['alice', 'bob', 'foobar']); // we expect 3 elements with the item class, but there are only 2 + * ``` + * + * Note that locator must point to a single element when passing a string or to multiple elements when passing an array. + * @param expected Expected classnames, whitespace separated. When passing an array, the given classes must be present on the locator elements. + * @param options + */ + toContainClass(expected: string|Array, options?: { + /** + * Whether to perform case-insensitive match. + */ + ignoreCase?: boolean; + + /** + * Time to retry the assertion for. Defaults to `timeout` in `TestConfig.expect`. + */ + timeout?: number; + }): Promise; + /** * Ensures the [Locator] points to an element that contains the given text. You can use regular expressions for the value * as well. @@ -3385,11 +3425,18 @@ interface LocatorAssertions { }): Promise; /** - * Ensures the [Locator] points to an element with given CSS class. + * Ensures the [Locator] points to an element with given CSS classes. This needs to be a full match or using a relaxed + * regular expression. For matching partial class names, use + * [locatorAssertions.toContainClass(expected[, options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-contain-class). + * + * ```html + *
+ * ``` * * ```js * const locator = page.locator('#component'); * await expect(locator).toHaveClass(/selected/); + * await expect(locator).toHaveClass('selected row'); * ``` * * Note that if array is passed as an expected value, entire lists of elements can be asserted: diff --git a/tests/playwright-test/playwright.expect.misc.spec.ts b/tests/playwright-test/playwright.expect.misc.spec.ts index b19ecda9c5..1fffc813a4 100644 --- a/tests/playwright-test/playwright.expect.misc.spec.ts +++ b/tests/playwright-test/playwright.expect.misc.spec.ts @@ -262,6 +262,60 @@ test('should support toHaveClass w/ array', async ({ runInlineTest }) => { expect(result.exitCode).toBe(1); }); +test('should support toContainClass', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + const { test } = pwt; + + test('pass', async ({ page }) => { + await page.setContent(\` +
+ + +
+ \`); + const locator = page.locator('div'); + await expect(locator).toContainClass('foo'); + // Leading/trailing whitespace + await expect(locator).toContainClass(' foo '); + // empty should not pass + await expect(locator).not.toContainClass(''); + await expect(locator).toContainClass('bar'); + await expect(locator).toContainClass('baz'); + await expect(locator).toContainClass('foo baz'); + await expect(locator).toContainClass('baz foo'); + await expect(locator).not.toContainClass('ba'); + + await expect(locator).toContainClass('BAZ FoO', { ignoreCase: true }); + await expect(locator).not.toContainClass('BAZ'); + + const locatorSpan = page.locator('div span'); + await expect(locatorSpan).toContainClass(['alice baz', 'bob']); + await expect(locatorSpan).not.toContainClass(['alice', 'alice']); + }); + + test('fail', async ({ page }) => { + await page.setContent('
'); + const locator = page.locator('div'); + await expect(locator).toContainClass('foo', { timeout: 1000 }); + }); + + test('fail length mismatch', async ({ page }) => { + await page.setContent('
'); + const locator = page.locator('div span'); + await expect(locator).toContainClass('alice', { timeout: 1000 }); + }); + `, + }, { workers: 1 }); + const output = stripAnsi(result.output); + expect(output).toContain('expect(locator).toContainClass'); + expect(output).toContain('Expected string: \"foo\"'); + expect(output).toContain('Received string: \"bar baz\"'); + expect(result.passed).toBe(1); + expect(result.failed).toBe(2); + expect(result.exitCode).toBe(1); +}); + test('should support toHaveTitle', async ({ runInlineTest }) => { const result = await runInlineTest({ 'a.test.ts': `