From 213e1d233163fb3de8f3a45cb73af0a6adb30c1e Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Tue, 23 Aug 2022 19:05:32 +0300 Subject: [PATCH 1/3] feat: expect(locator).toHaveAttribute to assert attribute presence This patch changes `expect(locator).toHaveAttribute()` so that the `value` argument can be omitted. When done so, the method will assert attribute existance. Fixes #16517 --- docs/src/api/class-locatorassertions.md | 4 ++-- packages/playwright-core/src/protocol/channels.ts | 2 ++ packages/playwright-core/src/protocol/protocol.yml | 1 + packages/playwright-core/src/protocol/validator.ts | 1 + .../playwright-core/src/server/injected/injectedScript.ts | 6 ++++-- packages/playwright-test/src/matchers/matchers.ts | 7 ++++++- packages/playwright-test/types/test.d.ts | 4 ++-- tests/page/expect-misc.spec.ts | 5 ++++- 8 files changed, 22 insertions(+), 8 deletions(-) diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index 68e1d87c46a95..735cf49592bc7 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -928,9 +928,9 @@ Attribute name. ### param: LocatorAssertions.toHaveAttribute.value * since: v1.18 -- `value` <[string]|[RegExp]> +- `value` ?<[string]|[RegExp]> -Expected attribute value. +Optional expected attribute value. If missing, method will assert attribute presence. ### option: LocatorAssertions.toHaveAttribute.timeout = %%-js-assertions-timeout-%% * since: v1.18 diff --git a/packages/playwright-core/src/protocol/channels.ts b/packages/playwright-core/src/protocol/channels.ts index 571151db012f0..b5a82c3a66eb8 100644 --- a/packages/playwright-core/src/protocol/channels.ts +++ b/packages/playwright-core/src/protocol/channels.ts @@ -2677,6 +2677,7 @@ export type FrameExpectParams = { expectedText?: ExpectedTextValue[], expectedNumber?: number, expectedValue?: SerializedArgument, + expectedExistance?: boolean, useInnerText?: boolean, isNot: boolean, timeout?: number, @@ -2686,6 +2687,7 @@ export type FrameExpectOptions = { expectedText?: ExpectedTextValue[], expectedNumber?: number, expectedValue?: SerializedArgument, + expectedExistance?: boolean, useInnerText?: boolean, timeout?: number, }; diff --git a/packages/playwright-core/src/protocol/protocol.yml b/packages/playwright-core/src/protocol/protocol.yml index 1791fd11a744d..de66117b6e67e 100644 --- a/packages/playwright-core/src/protocol/protocol.yml +++ b/packages/playwright-core/src/protocol/protocol.yml @@ -1984,6 +1984,7 @@ Frame: items: ExpectedTextValue expectedNumber: number? expectedValue: SerializedArgument? + expectedExistance: boolean? useInnerText: boolean? isNot: boolean timeout: number? diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 97d5d44002ce3..aea462fdd3a12 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1490,6 +1490,7 @@ scheme.FrameExpectParams = tObject({ expectedText: tOptional(tArray(tType('ExpectedTextValue'))), expectedNumber: tOptional(tNumber), expectedValue: tOptional(tType('SerializedArgument')), + expectedExistance: tOptional(tBoolean), useInnerText: tOptional(tBoolean), isNot: tBoolean, timeout: tOptional(tNumber), diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 312be0b8d29b8..225aab274472b 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -1024,7 +1024,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' && options.expectedExistance !== undefined) { + elementState = element.hasAttribute(options.expressionArg); + } else if (expression === 'to.be.checked') { elementState = progress.injectedScript.elementState(element, 'checked'); } else if (expression === 'to.be.unchecked') { elementState = progress.injectedScript.elementState(element, 'unchecked'); @@ -1082,7 +1084,7 @@ export class InjectedScript { { // Single text value. let received: string | undefined; - if (expression === 'to.have.attribute') { + if (expression === 'to.have.attribute' && options.expectedExistance === undefined) { received = element.getAttribute(options.expressionArg) || ''; } else if (expression === 'to.have.class') { received = element.classList.toString(); diff --git a/packages/playwright-test/src/matchers/matchers.ts b/packages/playwright-test/src/matchers/matchers.ts index 2441058ca337e..fc77581e99bfb 100644 --- a/packages/playwright-test/src/matchers/matchers.ts +++ b/packages/playwright-test/src/matchers/matchers.ts @@ -138,9 +138,14 @@ export function toHaveAttribute( this: ReturnType, locator: LocatorEx, name: string, - expected: string | RegExp, + expected: string | RegExp | undefined, options?: { timeout?: number }, ) { + if (expected === undefined) { + return toBeTruthy.call(this, 'toHaveAttribute', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return await locator._expect(customStackTrace, 'to.have.attribute', { expressionArg: name, expectedExistance: true, isNot, timeout }); + }, options); + } return toMatchText.call(this, 'toHaveAttribute', locator, 'Locator', async (isNot, timeout, customStackTrace) => { const expectedText = toExpectedTextValues([expected]); return await locator._expect(customStackTrace, 'to.have.attribute', { expressionArg: name, expectedText, isNot, timeout }); diff --git a/packages/playwright-test/types/test.d.ts b/packages/playwright-test/types/test.d.ts index 5471fed48f8f2..03aea97ddd210 100644 --- a/packages/playwright-test/types/test.d.ts +++ b/packages/playwright-test/types/test.d.ts @@ -3431,10 +3431,10 @@ interface LocatorAssertions { * ``` * * @param name Attribute name. - * @param value Expected attribute value. + * @param value Optional expected attribute value. If missing, method will assert attribute presence. * @param options */ - toHaveAttribute(name: string, value: string|RegExp, options?: { + toHaveAttribute(name: string, value?: string|RegExp, options?: { /** * Time to retry the assertion for. Defaults to `timeout` in `TestConfig.expect`. */ diff --git a/tests/page/expect-misc.spec.ts b/tests/page/expect-misc.spec.ts index 3b48c0d49e327..c3d09aac88600 100644 --- a/tests/page/expect-misc.spec.ts +++ b/tests/page/expect-misc.spec.ts @@ -228,8 +228,11 @@ test.describe('toHaveURL', () => { test.describe('toHaveAttribute', () => { test('pass', async ({ page }) => { - await page.setContent('
Text content
'); + 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'); await expect(locator).toHaveAttribute('id', 'node'); }); }); From 3a6e11b4ebc29c237316529f5b1a6df56f905c89 Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Wed, 24 Aug 2022 00:41:46 +0300 Subject: [PATCH 2/3] address comments --- packages/playwright-core/src/protocol/channels.ts | 2 -- packages/playwright-core/src/protocol/protocol.yml | 1 - packages/playwright-core/src/protocol/validator.ts | 1 - .../playwright-core/src/server/injected/injectedScript.ts | 4 ++-- packages/playwright-test/src/matchers/matchers.ts | 4 ++-- 5 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/playwright-core/src/protocol/channels.ts b/packages/playwright-core/src/protocol/channels.ts index b5a82c3a66eb8..571151db012f0 100644 --- a/packages/playwright-core/src/protocol/channels.ts +++ b/packages/playwright-core/src/protocol/channels.ts @@ -2677,7 +2677,6 @@ export type FrameExpectParams = { expectedText?: ExpectedTextValue[], expectedNumber?: number, expectedValue?: SerializedArgument, - expectedExistance?: boolean, useInnerText?: boolean, isNot: boolean, timeout?: number, @@ -2687,7 +2686,6 @@ export type FrameExpectOptions = { expectedText?: ExpectedTextValue[], expectedNumber?: number, expectedValue?: SerializedArgument, - expectedExistance?: boolean, useInnerText?: boolean, timeout?: number, }; diff --git a/packages/playwright-core/src/protocol/protocol.yml b/packages/playwright-core/src/protocol/protocol.yml index de66117b6e67e..1791fd11a744d 100644 --- a/packages/playwright-core/src/protocol/protocol.yml +++ b/packages/playwright-core/src/protocol/protocol.yml @@ -1984,7 +1984,6 @@ Frame: items: ExpectedTextValue expectedNumber: number? expectedValue: SerializedArgument? - expectedExistance: boolean? useInnerText: boolean? isNot: boolean timeout: number? diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index aea462fdd3a12..97d5d44002ce3 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1490,7 +1490,6 @@ scheme.FrameExpectParams = tObject({ expectedText: tOptional(tArray(tType('ExpectedTextValue'))), expectedNumber: tOptional(tNumber), expectedValue: tOptional(tType('SerializedArgument')), - expectedExistance: tOptional(tBoolean), useInnerText: tOptional(tBoolean), isNot: tBoolean, timeout: tOptional(tNumber), diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 225aab274472b..7f81571ff9944 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -1024,7 +1024,7 @@ export class InjectedScript { { // Element state / boolean values. let elementState: boolean | 'error:notconnected' | 'error:notcheckbox' | undefined; - if (expression === 'to.have.attribute' && options.expectedExistance !== undefined) { + if (expression === 'to.have.attribute') { elementState = element.hasAttribute(options.expressionArg); } else if (expression === 'to.be.checked') { elementState = progress.injectedScript.elementState(element, 'checked'); @@ -1084,7 +1084,7 @@ export class InjectedScript { { // Single text value. let received: string | undefined; - if (expression === 'to.have.attribute' && options.expectedExistance === undefined) { + if (expression === 'to.have.attribute.value') { received = element.getAttribute(options.expressionArg) || ''; } else if (expression === 'to.have.class') { received = element.classList.toString(); diff --git a/packages/playwright-test/src/matchers/matchers.ts b/packages/playwright-test/src/matchers/matchers.ts index fc77581e99bfb..b3aa9024ac74c 100644 --- a/packages/playwright-test/src/matchers/matchers.ts +++ b/packages/playwright-test/src/matchers/matchers.ts @@ -143,12 +143,12 @@ export function toHaveAttribute( ) { if (expected === undefined) { return toBeTruthy.call(this, 'toHaveAttribute', locator, 'Locator', async (isNot, timeout, customStackTrace) => { - return await locator._expect(customStackTrace, 'to.have.attribute', { expressionArg: name, expectedExistance: true, isNot, timeout }); + return await locator._expect(customStackTrace, 'to.have.attribute', { expressionArg: name, isNot, timeout }); }, options); } return toMatchText.call(this, 'toHaveAttribute', locator, 'Locator', async (isNot, timeout, customStackTrace) => { const expectedText = toExpectedTextValues([expected]); - return await locator._expect(customStackTrace, 'to.have.attribute', { expressionArg: name, expectedText, isNot, timeout }); + return await locator._expect(customStackTrace, 'to.have.attribute.value', { expressionArg: name, expectedText, isNot, timeout }); }, expected, options); } From 4cbd4677e82b92c2905b0186f8d1cfddbd1482b5 Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Thu, 25 Aug 2022 15:24:37 +0300 Subject: [PATCH 3/3] add examples --- docs/src/api/class-locatorassertions.md | 7 ++++++- packages/playwright-test/types/test.d.ts | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index 735cf49592bc7..20c4378c04a35 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -890,11 +890,16 @@ Whether to use `element.innerText` instead of `element.textContent` when retriev * langs: - alias-java: hasAttribute -Ensures the [Locator] points to an element with given attribute. +Ensures the [Locator] points to an element with given attribute. If the method +is used without `'value'` argument, then the method will assert attribute existance. ```js const locator = page.locator('input'); +// Assert attribute with given value. await expect(locator).toHaveAttribute('type', 'text'); +// Assert attribute existance. +await expect(locator).toHaveAttribute('disabled'); +await expect(locator).not.toHaveAttribute('open'); ``` ```java diff --git a/packages/playwright-test/types/test.d.ts b/packages/playwright-test/types/test.d.ts index 03aea97ddd210..18854276cdee1 100644 --- a/packages/playwright-test/types/test.d.ts +++ b/packages/playwright-test/types/test.d.ts @@ -3423,11 +3423,16 @@ interface LocatorAssertions { }): Promise; /** - * Ensures the [Locator] points to an element with given attribute. + * Ensures the [Locator] points to an element with given attribute. If the method is used without `'value'` argument, then + * the method will assert attribute existance. * * ```js * const locator = page.locator('input'); + * // Assert attribute with given value. * await expect(locator).toHaveAttribute('type', 'text'); + * // Assert attribute existance. + * await expect(locator).toHaveAttribute('disabled'); + * await expect(locator).not.toHaveAttribute('open'); * ``` * * @param name Attribute name.