Skip to content

Commit

Permalink
feat: toHaveAttribute without value (#27418)
Browse files Browse the repository at this point in the history
This time not doing it in other languages due to unjustified generator
complexity.

Fixes #27341
  • Loading branch information
yury-s authored Oct 4, 2023
1 parent 5295d46 commit ac48a47
Show file tree
Hide file tree
Showing 6 changed files with 100 additions and 7 deletions.
23 changes: 23 additions & 0 deletions docs/src/api/class-locatorassertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 };
Expand Down
22 changes: 17 additions & 5 deletions packages/playwright/src/matchers/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
20 changes: 20 additions & 0 deletions packages/playwright/types/test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5583,6 +5583,26 @@ interface LocatorAssertions {
timeout?: number;
}): Promise<void>;

/**
* 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<void>;

/**
* 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.
Expand Down
16 changes: 16 additions & 0 deletions tests/page/expect-misc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<div checked id=node>Text content</div>');
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('<div checked id=node>Text content</div>');
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', () => {
Expand Down
20 changes: 20 additions & 0 deletions tests/playwright-test/expect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

0 comments on commit ac48a47

Please sign in to comment.