diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index e87f712b7e..43c99a4a5e 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -1151,6 +1151,29 @@ Expected attribute value. ### option: LocatorAssertions.toHaveAttribute.timeout = %%-csharp-java-python-assertions-timeout-%% * since: v1.18 +## async method: LocatorAssertions.toHaveAttribute#2 +* since: v1.40 +* langs: js + +Ensures the [Locator] points to an element with given attribute. The method will assert attribute +presence. + +```js +const locator = page.locator('input'); +// Assert attribute existence. +await expect(locator).toHaveAttribute('disabled'); +await expect(locator).not.toHaveAttribute('open'); +``` + +### param: LocatorAssertions.toHaveAttribute#2.name +* since: v1.40 +- `name` <[string]> + +Attribute name. + +### option: LocatorAssertions.toHaveAttribute#2.timeout = %%-js-assertions-timeout-%% +* since: v1.40 + ## async method: LocatorAssertions.toHaveClass * since: v1.20 * langs: diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 6013f35567..cf0b51383a 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -1206,7 +1206,9 @@ export class InjectedScript { { // Element state / boolean values. let elementState: boolean | 'error:notconnected' | 'error:notcheckbox' | undefined; - if (expression === 'to.be.checked') { + if (expression === 'to.have.attribute') { + elementState = element.hasAttribute(options.expressionArg); + } else if (expression === 'to.be.checked') { elementState = this.elementState(element, 'checked'); } else if (expression === 'to.be.unchecked') { elementState = this.elementState(element, 'unchecked'); @@ -1277,7 +1279,7 @@ export class InjectedScript { { // Single text value. let received: string | undefined; - if (expression === 'to.have.attribute') { + if (expression === 'to.have.attribute.value') { const value = element.getAttribute(options.expressionArg); if (value === null) return { received: null, matches: false }; diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index b71bc47c6e..5f4013c62f 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -21,7 +21,7 @@ import { expectTypes, callLogText, filteredStackTrace } from '../util'; import { toBeTruthy } from './toBeTruthy'; import { toEqual } from './toEqual'; import { toExpectedTextValues, toMatchText } from './toMatchText'; -import { captureRawStack, constructURLBasedOnBaseURL, isTextualMimeType, pollAgainstDeadline } from 'playwright-core/lib/utils'; +import { captureRawStack, constructURLBasedOnBaseURL, isRegExp, isTextualMimeType, pollAgainstDeadline } from 'playwright-core/lib/utils'; import { currentTestInfo } from '../common/globals'; import { TestInfoImpl, type TestStepInternal } from '../worker/testInfo'; import type { ExpectMatcherContext } from './expect'; @@ -177,13 +177,25 @@ export function toHaveAttribute( this: ExpectMatcherContext, locator: LocatorEx, name: string, - expected: string | RegExp, + expected: string | RegExp | undefined | { timeout?: number }, options?: { timeout?: number }, ) { + if (!options) { + // Update params for the case toHaveAttribute(name, options); + if (typeof expected === 'object' && !isRegExp(expected)) { + options = expected; + expected = undefined; + } + } + if (expected === undefined) { + return toBeTruthy.call(this, 'toHaveAttribute', locator, 'Locator', 'have attribute', 'not have attribute', '', async (isNot, timeout) => { + return await locator._expect('to.have.attribute', { expressionArg: name, isNot, timeout }); + }, options); + } return toMatchText.call(this, 'toHaveAttribute', locator, 'Locator', async (isNot, timeout) => { - const expectedText = toExpectedTextValues([expected]); - return await locator._expect('to.have.attribute', { expressionArg: name, expectedText, isNot, timeout }); - }, expected, options); + const expectedText = toExpectedTextValues([expected as (string | RegExp)]); + return await locator._expect('to.have.attribute.value', { expressionArg: name, expectedText, isNot, timeout }); + }, expected as (string | RegExp), options); } export function toHaveClass( diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 3582e5505f..13bc565935 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -5583,6 +5583,26 @@ interface LocatorAssertions { timeout?: number; }): Promise; + /** + * Ensures the {@link Locator} points to an element with given attribute. The method will assert attribute presence. + * + * ```js + * const locator = page.locator('input'); + * // Assert attribute existence. + * await expect(locator).toHaveAttribute('disabled'); + * await expect(locator).not.toHaveAttribute('open'); + * ``` + * + * @param name Attribute name. + * @param options + */ + toHaveAttribute(name: string, options?: { + /** + * Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. + */ + timeout?: number; + }): Promise; + /** * Ensures the {@link Locator} points to an element with given CSS classes. This needs to be a full match or using a * relaxed regular expression. diff --git a/tests/page/expect-misc.spec.ts b/tests/page/expect-misc.spec.ts index afdb8e8803..eacfb786e4 100644 --- a/tests/page/expect-misc.spec.ts +++ b/tests/page/expect-misc.spec.ts @@ -262,6 +262,22 @@ test.describe('toHaveAttribute', () => { expect(error.message).toContain('expect.not.toHaveAttribute with timeout 1000ms'); } }); + + test('should match attribute without value', async ({ page }) => { + await page.setContent('
Text content
'); + const locator = page.locator('#node'); + await expect(locator).toHaveAttribute('id'); + await expect(locator).toHaveAttribute('checked'); + await expect(locator).not.toHaveAttribute('open'); + }); + + test('should support boolean attribute with options', async ({ page }) => { + await page.setContent('
Text content
'); + const locator = page.locator('#node'); + await expect(locator).toHaveAttribute('id', { timeout: 5000 }); + await expect(locator).toHaveAttribute('checked', { timeout: 5000 }); + await expect(locator).not.toHaveAttribute('open', { timeout: 5000 }); + }); }); test.describe('toHaveCSS', () => { diff --git a/tests/playwright-test/expect.spec.ts b/tests/playwright-test/expect.spec.ts index ed20b27e6f..ec7ae3c4de 100644 --- a/tests/playwright-test/expect.spec.ts +++ b/tests/playwright-test/expect.spec.ts @@ -859,3 +859,23 @@ test('should chain expect matchers and expose matcher utils', async ({ runInline expect(result.failed).toBe(1); expect(result.exitCode).toBe(1); }); + +test('should suppport toHaveAttribute without optional value', async ({ runTSC }) => { + const result = await runTSC({ + 'a.spec.ts': ` + import { test, expect as baseExpect } from '@playwright/test'; + test('custom matchers', async ({ page }) => { + const locator = page.locator('#node'); + await test.expect(locator).toHaveAttribute('name', 'value'); + await test.expect(locator).toHaveAttribute('name', 'value', { timeout: 10 }); + await test.expect(locator).toHaveAttribute('disabled'); + await test.expect(locator).toHaveAttribute('disabled', { timeout: 10 }); + // @ts-expect-error + await test.expect(locator).toHaveAttribute('disabled', { foo: 1 }); + // @ts-expect-error + await test.expect(locator).toHaveAttribute('name', 'value', 'opt'); + }); + ` + }); + expect(result.exitCode).toBe(0); +}); \ No newline at end of file