feat(expect): even more matchers (#7902)

This commit is contained in:
Pavel Feldman 2021-07-29 07:33:19 -07:00 committed by GitHub
parent 600d82b17c
commit 1807142eb7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 244 additions and 92 deletions

View File

@ -28,12 +28,14 @@ import {
toContainText,
toHaveAttr,
toHaveClass,
toHaveCount,
toHaveCSS,
toHaveData,
toHaveId,
toHaveLength,
toHaveProp,
toHaveText,
toHaveTitle,
toHaveURL,
toHaveValue
} from './matchers/matchers';
import { toMatchSnapshot } from './matchers/toMatchSnapshot';
@ -54,12 +56,14 @@ expectLibrary.extend({
toContainText,
toHaveAttr,
toHaveClass,
toHaveCount,
toHaveCSS,
toHaveData,
toHaveId,
toHaveLength,
toHaveProp,
toHaveText,
toHaveTitle,
toHaveURL,
toHaveValue,
toMatchSnapshot,
});

View File

@ -14,49 +14,48 @@
* limitations under the License.
*/
import matchers from 'expect/build/matchers';
import { Locator } from '../../..';
import { Locator, Page } from '../../..';
import type { Expect } from '../types';
import { toBeTruthy } from './toBeTruthy';
import { toEqual } from './toEqual';
import { toMatchText } from './toMatchText';
export async function toBeChecked(
export function toBeChecked(
this: ReturnType<Expect['getState']>,
locator: Locator,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeChecked', locator, async timeout => {
return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', async timeout => {
return await locator.isChecked({ timeout });
}, options);
}
export async function toBeDisabled(
export function toBeDisabled(
this: ReturnType<Expect['getState']>,
locator: Locator,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeDisabled', locator, async timeout => {
return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', async timeout => {
return await locator.isDisabled({ timeout });
}, options);
}
export async function toBeEditable(
export function toBeEditable(
this: ReturnType<Expect['getState']>,
locator: Locator,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeEditable', locator, async timeout => {
return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', async timeout => {
return await locator.isEditable({ timeout });
}, options);
}
export async function toBeEmpty(
export function toBeEmpty(
this: ReturnType<Expect['getState']>,
locator: Locator,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeEmpty', locator, async timeout => {
return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', async timeout => {
return await locator.evaluate(element => {
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA')
return !(element as HTMLInputElement).value;
@ -65,178 +64,212 @@ export async function toBeEmpty(
}, options);
}
export async function toBeEnabled(
export function toBeEnabled(
this: ReturnType<Expect['getState']>,
locator: Locator,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeEnabled', locator, async timeout => {
return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', async timeout => {
return await locator.isEnabled({ timeout });
}, options);
}
export async function toBeFocused(
export function toBeFocused(
this: ReturnType<Expect['getState']>,
locator: Locator,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeFocused', locator, async timeout => {
return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', async timeout => {
return await locator.evaluate(element => {
return document.activeElement === element;
}, { timeout });
}, options);
}
export async function toBeHidden(
export function toBeHidden(
this: ReturnType<Expect['getState']>,
locator: Locator,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeHidden', locator, async timeout => {
return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', async timeout => {
return await locator.isHidden({ timeout });
}, options);
}
export async function toBeSelected(
export function toBeSelected(
this: ReturnType<Expect['getState']>,
locator: Locator,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeSelected', locator, async timeout => {
return toBeTruthy.call(this, 'toBeSelected', locator, 'Locator', async timeout => {
return await locator.evaluate(element => {
return (element as HTMLOptionElement).selected;
}, { timeout });
}, options);
}
export async function toBeVisible(
export function toBeVisible(
this: ReturnType<Expect['getState']>,
locator: Locator,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeVisible', locator, async timeout => {
return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', async timeout => {
return await locator.isVisible({ timeout });
}, options);
}
export async function toContainText(
export function toContainText(
this: ReturnType<Expect['getState']>,
locator: Locator,
expected: string,
options?: { timeout?: number, useInnerText?: boolean },
) {
return toMatchText.call(this, 'toContainText', locator, async timeout => {
return toMatchText.call(this, 'toContainText', locator, 'Locator', async timeout => {
if (options?.useInnerText)
return await locator.innerText({ timeout });
return await locator.textContent() || '';
}, expected, { ...options, matchSubstring: true });
}
export async function toHaveAttr(
export function toHaveAttr(
this: ReturnType<Expect['getState']>,
locator: Locator,
name: string,
expected: string | RegExp,
options?: { timeout?: number },
) {
return toMatchText.call(this, 'toHaveAttr', locator, async timeout => {
return toMatchText.call(this, 'toHaveAttr', locator, 'Locator', async timeout => {
return await locator.getAttribute(name, { timeout }) || '';
}, expected, options);
}
export async function toHaveClass(
export function toHaveClass(
this: ReturnType<Expect['getState']>,
locator: Locator,
expected: string,
expected: string | RegExp | string[],
options?: { timeout?: number },
) {
return toMatchText.call(this, 'toHaveClass', locator, async timeout => {
return await locator.evaluate(element => element.className, { timeout });
}, expected, { ...options, matchSubstring: true });
if (Array.isArray(expected)) {
return toEqual.call(this, 'toHaveClass', locator, 'Locator', async () => {
return await locator.evaluateAll(ee => ee.map(e => e.className));
}, expected, options);
} else {
return toMatchText.call(this, 'toHaveClass', locator, 'Locator', async timeout => {
return await locator.evaluate(element => element.className, { timeout });
}, expected, options);
}
}
export async function toHaveCSS(
export function toHaveCount(
this: ReturnType<Expect['getState']>,
locator: Locator,
expected: number,
options?: { timeout?: number },
) {
return toEqual.call(this, 'toHaveCount', locator, 'Locator', async timeout => {
return await locator.count();
}, expected, options);
}
export function toHaveCSS(
this: ReturnType<Expect['getState']>,
locator: Locator,
name: string,
expected: string | RegExp,
options?: { timeout?: number },
) {
return toMatchText.call(this, 'toHaveCSS', locator, async timeout => {
return toMatchText.call(this, 'toHaveCSS', locator, 'Locator', async timeout => {
return await locator.evaluate(async (element, name) => {
return (window.getComputedStyle(element) as any)[name];
}, name, { timeout });
}, expected, options);
}
export async function toHaveData(
export function toHaveData(
this: ReturnType<Expect['getState']>,
locator: Locator,
name: string,
expected: string | RegExp,
options?: { timeout?: number },
) {
return toMatchText.call(this, 'toHaveData', locator, async timeout => {
return toMatchText.call(this, 'toHaveData', locator, 'Locator', async timeout => {
return await locator.getAttribute('data-' + name, { timeout }) || '';
}, expected, options);
}
export async function toHaveId(
export function toHaveId(
this: ReturnType<Expect['getState']>,
locator: Locator,
expected: string | RegExp,
options?: { timeout?: number },
) {
return toMatchText.call(this, 'toHaveId', locator, async timeout => {
return toMatchText.call(this, 'toHaveId', locator, 'Locator', async timeout => {
return await locator.getAttribute('id', { timeout }) || '';
}, expected, options);
}
export async function toHaveLength(
this: ReturnType<Expect['getState']>,
locator: Locator,
expected: number,
options?: { timeout?: number },
) {
if (typeof locator !== 'object' || locator.constructor.name !== 'Locator')
return matchers.toHaveLength.call(this, locator, expected);
return toEqual.call(this, 'toHaveLength', locator, async timeout => {
return await locator.count();
}, expected, { expectedType: 'number', ...options });
}
export async function toHaveProp(
export function toHaveProp(
this: ReturnType<Expect['getState']>,
locator: Locator,
name: string,
expected: number,
options?: { timeout?: number },
) {
return toEqual.call(this, 'toHaveProp', locator, async timeout => {
return toEqual.call(this, 'toHaveProp', locator, 'Locator', async timeout => {
return await locator.evaluate((element, name) => (element as any)[name], name, { timeout });
}, expected, { expectedType: 'number', ...options });
}
export async function toHaveText(
this: ReturnType<Expect['getState']>,
locator: Locator,
expected: string | RegExp,
options?: { timeout?: number, useInnerText?: boolean },
) {
return toMatchText.call(this, 'toHaveText', locator, async timeout => {
if (options?.useInnerText)
return await locator.innerText({ timeout });
return await locator.textContent() || '';
}, expected, options);
}
export async function toHaveValue(
export function toHaveText(
this: ReturnType<Expect['getState']>,
locator: Locator,
expected: string | RegExp | string[],
options?: { timeout?: number, useInnerText?: boolean },
) {
if (Array.isArray(expected)) {
return toEqual.call(this, 'toHaveText', locator, 'Locator', async () => {
return locator.evaluateAll((ee, useInnerText) => {
return ee.map(e => useInnerText ? (e as HTMLElement).innerText : e.textContent || '');
}, options?.useInnerText);
}, expected, options);
} else {
return toMatchText.call(this, 'toHaveText', locator, 'Locator', async timeout => {
if (options?.useInnerText)
return await locator.innerText({ timeout });
return await locator.textContent() || '';
}, expected, options);
}
}
export function toHaveTitle(
this: ReturnType<Expect['getState']>,
page: Page,
expected: string | RegExp,
options?: { timeout?: number },
) {
return toMatchText.call(this, 'toHaveTitle', page, 'Page', async () => {
return await page.title();
}, expected, options);
}
export function toHaveURL(
this: ReturnType<Expect['getState']>,
page: Page,
expected: string | RegExp,
options?: { timeout?: number },
) {
return toMatchText.call(this, 'toHaveURL', page, 'Page', async () => {
return page.url();
}, expected, options);
}
export function toHaveValue(
this: ReturnType<Expect['getState']>,
locator: Locator,
expected: string | RegExp,
options?: { timeout?: number },
) {
return toMatchText.call(this, 'toHaveValue', locator, async timeout => {
return toMatchText.call(this, 'toHaveValue', locator, 'Locator', async timeout => {
return await locator.inputValue({ timeout });
}, expected, options);
}

View File

@ -18,22 +18,22 @@ import {
matcherHint,
MatcherHintOptions
} from 'jest-matcher-utils';
import { Locator } from '../../..';
import { currentTestInfo } from '../globals';
import type { Expect } from '../types';
import { expectLocator, monotonicTime, pollUntilDeadline } from '../util';
import { expectType, monotonicTime, pollUntilDeadline } from '../util';
export async function toBeTruthy<T>(
this: ReturnType<Expect['getState']>,
matcherName: string,
locator: Locator,
receiver: any,
receiverType: string,
query: (timeout: number) => Promise<T>,
options: { timeout?: number } = {},
) {
const testInfo = currentTestInfo();
if (!testInfo)
throw new Error(`${matcherName} must be called during the test`);
expectLocator(locator, matcherName);
expectType(receiver, receiverType, matcherName);
const matcherOptions: MatcherHintOptions = {
isNot: this.isNot,

View File

@ -25,10 +25,9 @@ import {
printReceived,
stringify
} from 'jest-matcher-utils';
import { Locator } from '../../..';
import { currentTestInfo } from '../globals';
import type { Expect } from '../types';
import { expectLocator, monotonicTime, pollUntilDeadline } from '../util';
import { expectType, monotonicTime, pollUntilDeadline } from '../util';
// Omit colon and one or more spaces, so can call getLabelPrinter.
const EXPECTED_LABEL = 'Expected';
@ -40,7 +39,8 @@ const isExpand = (expand?: boolean): boolean => expand !== false;
export async function toEqual<T>(
this: ReturnType<Expect['getState']>,
matcherName: string,
locator: Locator,
receiver: any,
receiverType: string,
query: (timeout: number) => Promise<T>,
expected: T,
options: { timeout?: number } = {},
@ -48,7 +48,7 @@ export async function toEqual<T>(
const testInfo = currentTestInfo();
if (!testInfo)
throw new Error(`${matcherName} must be called during the test`);
expectLocator(locator, matcherName);
expectType(receiver, receiverType, matcherName);
const matcherOptions: MatcherHintOptions = {
comment: 'deep equality',

View File

@ -28,15 +28,15 @@ import {
printReceived,
printWithType,
} from 'jest-matcher-utils';
import { Locator } from '../../..';
import { currentTestInfo } from '../globals';
import type { Expect } from '../types';
import { expectLocator, monotonicTime, pollUntilDeadline } from '../util';
import { expectType, monotonicTime, pollUntilDeadline } from '../util';
export async function toMatchText(
this: ReturnType<Expect['getState']>,
matcherName: string,
locator: Locator,
receiver: any,
receiverType: string,
query: (timeout: number) => Promise<string>,
expected: string | RegExp,
options: { timeout?: number, matchSubstring?: boolean } = {},
@ -44,7 +44,7 @@ export async function toMatchText(
const testInfo = currentTestInfo();
if (!testInfo)
throw new Error(`${matcherName} must be called during the test`);
expectLocator(locator, matcherName);
expectType(receiver, receiverType, matcherName);
const matcherOptions: MatcherHintOptions = {
isNot: this.isNot,

View File

@ -187,7 +187,7 @@ export function errorWithLocation(location: Location, message: string) {
return new Error(`${formatLocation(location)}: ${message}`);
}
export function expectLocator(receiver: any, matcherName: string) {
if (typeof receiver !== 'object' || receiver.constructor.name !== 'Locator')
throw new Error(`${matcherName} can be only used with Locator object`);
export function expectType(receiver: any, type: string, matcherName: string) {
if (typeof receiver !== 'object' || receiver.constructor.name !== type)
throw new Error(`${matcherName} can be only used with ${type} object`);
}

View File

@ -16,7 +16,7 @@
import { test, expect, stripAscii } from './playwright-test-fixtures';
test('should support toHaveLength', async ({ runInlineTest }) => {
test('should support toHaveCount', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
const { test } = pwt;
@ -24,8 +24,7 @@ test('should support toHaveLength', async ({ runInlineTest }) => {
test('pass', async ({ page }) => {
await page.setContent('<select><option>One</option><option>Two</option></select>');
const locator = page.locator('option');
await expect(locator).toHaveLength(2);
await expect([1, 2]).toHaveLength(2);
await expect(locator).toHaveCount(2);
});
`,
}, { workers: 1 });
@ -68,19 +67,93 @@ test('should support toHaveClass', async ({ runInlineTest }) => {
test('pass', async ({ page }) => {
await page.setContent('<div class="foo bar baz"></div>');
const locator = page.locator('div');
await expect(locator).toHaveClass('foo');
await expect(locator).toHaveClass('foo bar baz');
});
test('fail', async ({ page }) => {
await page.setContent('<div class="bar baz"></div>');
const locator = page.locator('div');
await expect(locator).toHaveClass('foo', { timeout: 1000 });
await expect(locator).toHaveClass('foo bar baz', { timeout: 1000 });
});
`,
}, { workers: 1 });
const output = stripAscii(result.output);
expect(output).toContain('expect(locator).toHaveClass');
expect(output).toContain('Expected substring: \"foo\"');
expect(output).toContain('Expected string: \"foo bar baz\"');
expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
expect(result.exitCode).toBe(1);
});
test('should support toHaveClass w/ array', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
const { test } = pwt;
test('pass', async ({ page }) => {
await page.setContent('<div class="foo"></div><div class="bar"></div><div class="baz"></div>');
const locator = page.locator('div');
await expect(locator).toHaveClass(['foo', 'bar', 'baz']);
});
test('fail', async ({ page }) => {
await page.setContent('<div class="foo"></div><div class="bar"></div><div class="bar"></div>');
const locator = page.locator('div');
await expect(locator).toHaveClass(['foo', 'bar', 'baz'], { timeout: 1000 });
});
`,
}, { workers: 1 });
const output = stripAscii(result.output);
expect(output).toContain('expect(received).toHaveClass(expected)');
expect(output).toContain('- \"baz\",');
expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
expect(result.exitCode).toBe(1);
});
test('should support toHaveTitle', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
const { test } = pwt;
test('pass', async ({ page }) => {
await page.setContent('<title>Hello</title>');
await expect(page).toHaveTitle('Hello');
});
test('fail', async ({ page }) => {
await page.setContent('<title>Bye</title>');
await expect(page).toHaveTitle('Hello', { timeout: 100 });
});
`,
}, { workers: 1 });
const output = stripAscii(result.output);
expect(output).toContain('expect(page).toHaveTitle');
expect(output).toContain('Expected string: \"Hello\"');
expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
expect(result.exitCode).toBe(1);
});
test('should support toHaveURL', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
const { test } = pwt;
test('pass', async ({ page }) => {
await page.goto('data:text/html,<div>A</div>');
await expect(page).toHaveURL('data:text/html,<div>A</div>');
});
test('fail', async ({ page }) => {
await page.goto('data:text/html,<div>B</div>');
await expect(page).toHaveURL('wrong', { timeout: 100 });
});
`,
}, { workers: 1 });
const output = stripAscii(result.output);
expect(output).toContain('expect(page).toHaveURL');
expect(output).toContain('Expected string: \"wrong\"');
expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
expect(result.exitCode).toBe(1);

View File

@ -78,6 +78,33 @@ test('should support toHaveText w/ text', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(1);
});
test('should support toHaveText w/ array', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
const { test } = pwt;
test('pass', async ({ page }) => {
await page.setContent('<div>Text 1</div><div>Text 2</div>');
const locator = page.locator('div');
await expect(locator).toHaveText(['Text 1', 'Text 2']);
});
test('fail', async ({ page }) => {
await page.setContent('<div>Text 1</div><div>Text 3</div>');
const locator = page.locator('div');
await expect(locator).toHaveText(['Text 1', 'Text 2'], { timeout: 1000 });
});
`,
}, { workers: 1 });
const output = stripAscii(result.output);
expect(output).toContain('Error: expect(received).toHaveText(expected) // deep equality');
expect(output).toContain('await expect(locator).toHaveText');
expect(output).toContain('- \"Text 2\"');
expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
expect(result.exitCode).toBe(1);
});
test('should support toHaveText eventually', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `

23
types/testExpect.d.ts vendored
View File

@ -127,7 +127,12 @@ declare global {
/**
* Asserts that DOM node has a given CSS class.
*/
toHaveClass(className: string, options?: { timeout?: number }): Promise<R>;
toHaveClass(className: string | RegExp | string[], options?: { timeout?: number }): Promise<R>;
/**
* Asserts number of DOM nodes matching given locator.
*/
toHaveCount(expected: number, options?: { timeout?: number }): Promise<R>;
/**
* Asserts element's computed CSS property `name` matches expected value.
@ -150,11 +155,21 @@ declare global {
toHaveProp(name: string, value: any, options?: { timeout?: number }): Promise<R>;
/**
* Asserts element's exact text content.
* Asserts element's text content.
*/
toHaveText(expected: string | RegExp, options?: { timeout?: number, useInnerText?: boolean }): Promise<R>;
toHaveText(expected: string | RegExp | string[], options?: { timeout?: number, useInnerText?: boolean }): Promise<R>;
/**
* Asserts page's title.
*/
toHaveTitle(expected: string | RegExp, options?: { timeout?: number }): Promise<R>;
/**
* Asserts page's title.
*/
toHaveURL(expected: string | RegExp, options?: { timeout?: number }): Promise<R>;
/**
* Asserts input element's value.
*/
toHaveValue(expected: string | RegExp, options?: { timeout?: number }): Promise<R>;