diff --git a/src/test/expect.ts b/src/test/expect.ts index 815d8c2c4b..980f499b1f 100644 --- a/src/test/expect.ts +++ b/src/test/expect.ts @@ -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, }); diff --git a/src/test/matchers/matchers.ts b/src/test/matchers/matchers.ts index 73ce4bf877..8a4d71f0f3 100644 --- a/src/test/matchers/matchers.ts +++ b/src/test/matchers/matchers.ts @@ -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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, + 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, 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, 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, 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, - 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, 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, - 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, + 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, + 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, + 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, 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); } diff --git a/src/test/matchers/toBeTruthy.ts b/src/test/matchers/toBeTruthy.ts index 8c1b3404bd..5d2240268f 100644 --- a/src/test/matchers/toBeTruthy.ts +++ b/src/test/matchers/toBeTruthy.ts @@ -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( this: ReturnType, matcherName: string, - locator: Locator, + receiver: any, + receiverType: string, query: (timeout: number) => Promise, 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, diff --git a/src/test/matchers/toEqual.ts b/src/test/matchers/toEqual.ts index bb72df8630..799b44c653 100644 --- a/src/test/matchers/toEqual.ts +++ b/src/test/matchers/toEqual.ts @@ -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( this: ReturnType, matcherName: string, - locator: Locator, + receiver: any, + receiverType: string, query: (timeout: number) => Promise, expected: T, options: { timeout?: number } = {}, @@ -48,7 +48,7 @@ export async function toEqual( 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', diff --git a/src/test/matchers/toMatchText.ts b/src/test/matchers/toMatchText.ts index b1e8afdd1d..515820fd7b 100644 --- a/src/test/matchers/toMatchText.ts +++ b/src/test/matchers/toMatchText.ts @@ -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, matcherName: string, - locator: Locator, + receiver: any, + receiverType: string, query: (timeout: number) => Promise, 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, diff --git a/src/test/util.ts b/src/test/util.ts index 3954294ab9..f0bcd56dfe 100644 --- a/src/test/util.ts +++ b/src/test/util.ts @@ -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`); } diff --git a/tests/playwright-test/playwright.expect.misc.spec.ts b/tests/playwright-test/playwright.expect.misc.spec.ts index 7eb185290f..e3c0dfbf8c 100644 --- a/tests/playwright-test/playwright.expect.misc.spec.ts +++ b/tests/playwright-test/playwright.expect.misc.spec.ts @@ -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(''); 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('
'); const locator = page.locator('div'); - await expect(locator).toHaveClass('foo'); + await expect(locator).toHaveClass('foo bar baz'); }); test('fail', async ({ page }) => { await page.setContent('
'); 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('
'); + const locator = page.locator('div'); + await expect(locator).toHaveClass(['foo', 'bar', 'baz']); + }); + + test('fail', async ({ page }) => { + await page.setContent('
'); + 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('Hello'); + await expect(page).toHaveTitle('Hello'); + }); + + test('fail', async ({ page }) => { + await page.setContent('Bye'); + 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,
A
'); + await expect(page).toHaveURL('data:text/html,
A
'); + }); + + test('fail', async ({ page }) => { + await page.goto('data:text/html,
B
'); + 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); diff --git a/tests/playwright-test/playwright.expect.text.spec.ts b/tests/playwright-test/playwright.expect.text.spec.ts index 5934e4a01e..ebfb71a33c 100644 --- a/tests/playwright-test/playwright.expect.text.spec.ts +++ b/tests/playwright-test/playwright.expect.text.spec.ts @@ -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('
Text 1
Text 2
'); + const locator = page.locator('div'); + await expect(locator).toHaveText(['Text 1', 'Text 2']); + }); + + test('fail', async ({ page }) => { + await page.setContent('
Text 1
Text 3
'); + 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': ` diff --git a/types/testExpect.d.ts b/types/testExpect.d.ts index 80ef86cd13..91d325c414 100644 --- a/types/testExpect.d.ts +++ b/types/testExpect.d.ts @@ -127,7 +127,12 @@ declare global { /** * Asserts that DOM node has a given CSS class. */ - toHaveClass(className: string, options?: { timeout?: number }): Promise; + toHaveClass(className: string | RegExp | string[], options?: { timeout?: number }): Promise; + + /** + * Asserts number of DOM nodes matching given locator. + */ + toHaveCount(expected: number, options?: { timeout?: number }): Promise; /** * 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; /** - * Asserts element's exact text content. + * Asserts element's text content. */ - toHaveText(expected: string | RegExp, options?: { timeout?: number, useInnerText?: boolean }): Promise; - + toHaveText(expected: string | RegExp | string[], options?: { timeout?: number, useInnerText?: boolean }): Promise; + /** + * Asserts page's title. + */ + toHaveTitle(expected: string | RegExp, options?: { timeout?: number }): Promise; + + /** + * Asserts page's title. + */ + toHaveURL(expected: string | RegExp, options?: { timeout?: number }): Promise; + + /** * Asserts input element's value. */ toHaveValue(expected: string | RegExp, options?: { timeout?: number }): Promise;