diff --git a/src/test/expect.ts b/src/test/expect.ts index 57548413e5..fbf061fa45 100644 --- a/src/test/expect.ts +++ b/src/test/expect.ts @@ -15,9 +15,29 @@ */ import expectLibrary from 'expect'; -import { toBeChecked, toBeDisabled, toBeEditable, toBeEmpty, toBeEnabled, toBeFocused, toBeHidden, toBeVisible } from './matchers/toBeTruthy'; +import { + toBeChecked, + toBeDisabled, + toBeEditable, + toBeEmpty, + toBeEnabled, + toBeFocused, + toBeHidden, + toBeSelected, + toBeVisible +} from './matchers/toBeTruthy'; +import { toHaveLength, toHaveProp } from './matchers/toEqual'; import { toMatchSnapshot } from './matchers/toMatchSnapshot'; -import { toContainText, toHaveAttr, toHaveCSS, toHaveData, toHaveId, toHaveText, toHaveValue } from './matchers/toMatchText'; +import { + toContainText, + toHaveAttr, + toHaveCSS, + toHaveClass, + toHaveData, + toHaveId, + toHaveText, + toHaveValue +} from './matchers/toMatchText'; import type { Expect } from './types'; export const expect: Expect = expectLibrary as any; @@ -30,12 +50,16 @@ expectLibrary.extend({ toBeEnabled, toBeFocused, toBeHidden, + toBeSelected, toBeVisible, toContainText, toHaveAttr, toHaveCSS, + toHaveClass, toHaveData, toHaveId, + toHaveLength, + toHaveProp, toHaveText, toHaveValue, toMatchSnapshot, diff --git a/src/test/matchers/toBeTruthy.ts b/src/test/matchers/toBeTruthy.ts index 9509859dc8..0b7b708c6c 100644 --- a/src/test/matchers/toBeTruthy.ts +++ b/src/test/matchers/toBeTruthy.ts @@ -16,50 +16,46 @@ import { matcherHint, - MatcherHintOptions, - printReceived + MatcherHintOptions } from 'jest-matcher-utils'; import { Locator } from '../../..'; import { currentTestInfo } from '../globals'; import type { Expect } from '../types'; -import { monotonicTime, pollUntilDeadline } from '../util'; +import { expectLocator, monotonicTime, pollUntilDeadline } from '../util'; - -async function toBeTruthyImpl( +async function toBeTruthyImpl( this: ReturnType, matcherName: string, - query: (timeout: number) => Promise, + locator: Locator, + query: (timeout: number) => Promise, options: { timeout?: number } = {}, ) { const testInfo = currentTestInfo(); if (!testInfo) - throw new Error(`toMatchSnapshot() must be called during the test`); + throw new Error(`${matcherName} must be called during the test`); + expectLocator(locator, matcherName); const matcherOptions: MatcherHintOptions = { isNot: this.isNot, promise: this.promise, }; - let received: boolean; + let received: T; let pass = false; const timeout = options.timeout === 0 ? 0 : options.timeout || testInfo.timeout; const deadline = timeout ? monotonicTime() + timeout : 0; - try { - await pollUntilDeadline(async () => { - const remainingTime = deadline ? deadline - monotonicTime() : 0; - received = await query(remainingTime); - pass = !!received; - return pass === !matcherOptions.isNot; - }, deadline, 100); - } catch (e) { - pass = false; - } + // TODO: interrupt on timeout for nice message. + await pollUntilDeadline(async () => { + const remainingTime = deadline ? deadline - monotonicTime() : 0; + received = await query(remainingTime); + pass = !!received; + return pass === !matcherOptions.isNot; + }, deadline, 100); - const message = () => - matcherHint(matcherName, undefined, '', matcherOptions) + - '\n\n' + - `Received: ${printReceived(received)}`; + const message = () => { + return matcherHint(matcherName, undefined, '', matcherOptions); + }; return { message, pass }; } @@ -69,7 +65,7 @@ export async function toBeChecked( locator: Locator, options?: { timeout?: number }, ) { - return toBeTruthyImpl.call(this, 'toBeChecked', async timeout => { + return toBeTruthyImpl.call(this, 'toBeChecked', locator, async timeout => { return await locator.isChecked({ timeout }); }, options); } @@ -79,7 +75,7 @@ export async function toBeEditable( locator: Locator, options?: { timeout?: number }, ) { - return toBeTruthyImpl.call(this, 'toBeEditable', async timeout => { + return toBeTruthyImpl.call(this, 'toBeEditable', locator, async timeout => { return await locator.isEditable({ timeout }); }, options); } @@ -89,7 +85,7 @@ export async function toBeEnabled( locator: Locator, options?: { timeout?: number }, ) { - return toBeTruthyImpl.call(this, 'toBeEnabled', async timeout => { + return toBeTruthyImpl.call(this, 'toBeEnabled', locator, async timeout => { return await locator.isEnabled({ timeout }); }, options); } @@ -99,7 +95,7 @@ export async function toBeDisabled( locator: Locator, options?: { timeout?: number }, ) { - return toBeTruthyImpl.call(this, 'toBeDisabled', async timeout => { + return toBeTruthyImpl.call(this, 'toBeDisabled', locator, async timeout => { return await locator.isDisabled({ timeout }); }, options); } @@ -109,7 +105,7 @@ export async function toBeEmpty( locator: Locator, options?: { timeout?: number }, ) { - return toBeTruthyImpl.call(this, 'toBeEmpty', async timeout => { + return toBeTruthyImpl.call(this, 'toBeEmpty', locator, async timeout => { return await locator.evaluate(element => { if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') return !(element as HTMLInputElement).value; @@ -123,7 +119,7 @@ export async function toBeHidden( locator: Locator, options?: { timeout?: number }, ) { - return toBeTruthyImpl.call(this, 'toBeHidden', async timeout => { + return toBeTruthyImpl.call(this, 'toBeHidden', locator, async timeout => { return await locator.isHidden({ timeout }); }, options); } @@ -133,7 +129,7 @@ export async function toBeVisible( locator: Locator, options?: { timeout?: number }, ) { - return toBeTruthyImpl.call(this, 'toBeVisible', async timeout => { + return toBeTruthyImpl.call(this, 'toBeVisible', locator, async timeout => { return await locator.isVisible({ timeout }); }, options); } @@ -143,9 +139,21 @@ export async function toBeFocused( locator: Locator, options?: { timeout?: number }, ) { - return toBeTruthyImpl.call(this, 'toBeFocused', async timeout => { + return toBeTruthyImpl.call(this, 'toBeFocused', locator, async timeout => { return await locator.evaluate(element => { return document.activeElement === element; }, { timeout }); }, options); } + +export async function toBeSelected( + this: ReturnType, + locator: Locator, + options?: { timeout?: number }, +) { + return toBeTruthyImpl.call(this, 'toBeSelected', locator, async timeout => { + return await locator.evaluate(element => { + return (element as HTMLOptionElement).selected; + }, { timeout }); + }, options); +} diff --git a/src/test/matchers/toEqual.ts b/src/test/matchers/toEqual.ts new file mode 100644 index 0000000000..d20a1fc3db --- /dev/null +++ b/src/test/matchers/toEqual.ts @@ -0,0 +1,121 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { equals } from 'expect/build/jasmineUtils'; +import matchers from 'expect/build/matchers'; +import { + iterableEquality +} from 'expect/build/utils'; +import { + matcherHint, MatcherHintOptions, + printDiffOrStringify, + printExpected, + printReceived, + stringify +} from 'jest-matcher-utils'; +import { Locator } from '../../..'; +import { currentTestInfo } from '../globals'; +import type { Expect } from '../types'; +import { expectLocator, monotonicTime, pollUntilDeadline } from '../util'; + +// Omit colon and one or more spaces, so can call getLabelPrinter. +const EXPECTED_LABEL = 'Expected'; +const RECEIVED_LABEL = 'Received'; + +// The optional property of matcher context is true if undefined. +const isExpand = (expand?: boolean): boolean => expand !== false; + +async function toEqualImpl( + this: ReturnType, + matcherName: string, + locator: Locator, + query: (timeout: number) => Promise, + expected: T, + options: { timeout?: number } = {}, +) { + const testInfo = currentTestInfo(); + if (!testInfo) + throw new Error(`${matcherName} must be called during the test`); + expectLocator(locator, matcherName); + + const matcherOptions: MatcherHintOptions = { + comment: 'deep equality', + isNot: this.isNot, + promise: this.promise, + }; + + let received: T | undefined = undefined; + let pass = false; + const timeout = options.timeout === 0 ? 0 : options.timeout || testInfo.timeout; + const deadline = timeout ? monotonicTime() + timeout : 0; + + // TODO: interrupt on timeout for nice message. + await pollUntilDeadline(async () => { + const remainingTime = deadline ? deadline - monotonicTime() : 0; + received = await query(remainingTime); + pass = equals(received, expected, [iterableEquality]); + return pass === !matcherOptions.isNot; + }, deadline, 100); + + const message = pass + ? () => + matcherHint(matcherName, undefined, undefined, matcherOptions) + + '\n\n' + + `Expected: not ${printExpected(expected)}\n` + + (stringify(expected) !== stringify(received) + ? `Received: ${printReceived(received)}` + : '') + : () => + matcherHint(matcherName, undefined, undefined, matcherOptions) + + '\n\n' + + printDiffOrStringify( + expected, + received, + EXPECTED_LABEL, + RECEIVED_LABEL, + isExpand(this.expand), + ); + + // Passing the actual and expected objects so that a custom reporter + // could access them, for example in order to display a custom visual diff, + // or create a different error message + return { actual: received, expected, message, name: matcherName, pass }; +} + +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 toEqualImpl.call(this, 'toHaveLength', locator, async timeout => { + return await locator.count(); + }, expected, { expectedType: 'number', ...options }); +} + +export async function toHaveProp( + this: ReturnType, + locator: Locator, + name: string, + expected: number, + options?: { timeout?: number }, +) { + return toEqualImpl.call(this, 'toHaveProp', locator, async timeout => { + return await locator.evaluate((element, name) => (element as any)[name], name, { timeout }); + }, expected, { expectedType: 'number', ...options }); +} diff --git a/src/test/matchers/toMatchText.ts b/src/test/matchers/toMatchText.ts index bbe2389a4c..4254625dfe 100644 --- a/src/test/matchers/toMatchText.ts +++ b/src/test/matchers/toMatchText.ts @@ -31,18 +31,20 @@ import { import { Locator } from '../../..'; import { currentTestInfo } from '../globals'; import type { Expect } from '../types'; -import { monotonicTime, pollUntilDeadline } from '../util'; +import { expectLocator, monotonicTime, pollUntilDeadline } from '../util'; async function toMatchTextImpl( this: ReturnType, matcherName: string, + locator: Locator, query: (timeout: number) => Promise, expected: string | RegExp, options: { timeout?: number, matchSubstring?: boolean } = {}, ) { const testInfo = currentTestInfo(); if (!testInfo) - throw new Error(`toMatchSnapshot() must be called during the test`); + throw new Error(`${matcherName} must be called during the test`); + expectLocator(locator, matcherName); const matcherOptions: MatcherHintOptions = { isNot: this.isNot, @@ -69,22 +71,19 @@ async function toMatchTextImpl( const timeout = options.timeout === 0 ? 0 : options.timeout || testInfo.timeout; const deadline = timeout ? monotonicTime() + timeout : 0; - try { - await pollUntilDeadline(async () => { - const remainingTime = deadline ? deadline - monotonicTime() : 0; - received = await query(remainingTime); - if (options.matchSubstring) - pass = received.includes(expected as string); - else if (typeof expected === 'string') - pass = received === expected; - else - pass = expected.test(received); + // TODO: interrupt on timeout for nice message. + await pollUntilDeadline(async () => { + const remainingTime = deadline ? deadline - monotonicTime() : 0; + received = await query(remainingTime); + if (options.matchSubstring) + pass = received.includes(expected as string); + else if (typeof expected === 'string') + pass = received === expected; + else + pass = expected.test(received); - return pass === !matcherOptions.isNot; - }, deadline, 100); - } catch (e) { - pass = false; - } + return pass === !matcherOptions.isNot; + }, deadline, 100); const stringSubstring = options.matchSubstring ? 'substring' : 'string'; const message = pass @@ -130,7 +129,7 @@ export async function toHaveText( expected: string | RegExp, options?: { timeout?: number, useInnerText?: boolean }, ) { - return toMatchTextImpl.call(this, 'toHaveText', async timeout => { + return toMatchTextImpl.call(this, 'toHaveText', locator, async timeout => { if (options?.useInnerText) return await locator.innerText({ timeout }); return await locator.textContent() || ''; @@ -143,7 +142,7 @@ export async function toContainText( expected: string, options?: { timeout?: number, useInnerText?: boolean }, ) { - return toMatchTextImpl.call(this, 'toContainText', async timeout => { + return toMatchTextImpl.call(this, 'toContainText', locator, async timeout => { if (options?.useInnerText) return await locator.innerText({ timeout }); return await locator.textContent() || ''; @@ -157,7 +156,7 @@ export async function toHaveAttr( expected: string | RegExp, options?: { timeout?: number }, ) { - return toMatchTextImpl.call(this, 'toHaveAttr', async timeout => { + return toMatchTextImpl.call(this, 'toHaveAttr', locator, async timeout => { return await locator.getAttribute(name, { timeout }) || ''; }, expected, options); } @@ -169,7 +168,7 @@ export async function toHaveData( expected: string | RegExp, options?: { timeout?: number }, ) { - return toMatchTextImpl.call(this, 'toHaveData', async timeout => { + return toMatchTextImpl.call(this, 'toHaveData', locator, async timeout => { return await locator.getAttribute('data-' + name, { timeout }) || ''; }, expected, options); } @@ -181,7 +180,7 @@ export async function toHaveCSS( expected: string | RegExp, options?: { timeout?: number }, ) { - return toMatchTextImpl.call(this, 'toHaveCSS', async timeout => { + return toMatchTextImpl.call(this, 'toHaveCSS', locator, async timeout => { return await locator.evaluate(async (element, name) => { return (window.getComputedStyle(element) as any)[name]; }, name, { timeout }); @@ -194,7 +193,7 @@ export async function toHaveId( expected: string | RegExp, options?: { timeout?: number }, ) { - return toMatchTextImpl.call(this, 'toHaveId', async timeout => { + return toMatchTextImpl.call(this, 'toHaveId', locator, async timeout => { return await locator.getAttribute('id', { timeout }) || ''; }, expected, options); } @@ -205,7 +204,17 @@ export async function toHaveValue( expected: string | RegExp, options?: { timeout?: number }, ) { - return toMatchTextImpl.call(this, 'toHaveValue', async timeout => { + return toMatchTextImpl.call(this, 'toHaveValue', locator, async timeout => { return await locator.inputValue({ timeout }); }, expected, options); -} \ No newline at end of file +} +export async function toHaveClass( + this: ReturnType, + locator: Locator, + expected: string, + options?: { timeout?: number }, +) { + return toMatchTextImpl.call(this, 'toHaveClass', locator, async timeout => { + return await locator.evaluate(element => element.className, { timeout }); + }, expected, { ...options, matchSubstring: true }); +} diff --git a/src/test/util.ts b/src/test/util.ts index 9f1b4a4212..3954294ab9 100644 --- a/src/test/util.ts +++ b/src/test/util.ts @@ -18,7 +18,7 @@ import util from 'util'; import path from 'path'; import type { TestError, Location } from './types'; import { default as minimatch } from 'minimatch'; -import { TimeoutError } from '../utils/errors'; +import { errors } from '../..'; export class DeadlineRunner { private _timer: NodeJS.Timer | undefined; @@ -72,14 +72,19 @@ export async function raceAgainstDeadline(promise: Promise, deadline: numb export async function pollUntilDeadline(func: () => Promise, deadline: number, delay: number): Promise { while (true) { - if (await func()) - return; - const timeUntilDeadline = deadline ? deadline - monotonicTime() : Number.MAX_VALUE; - if (timeUntilDeadline > 0) - await new Promise(f => setTimeout(f, Math.min(timeUntilDeadline, delay))); - else - throw new TimeoutError('Timed out while waiting for condition to be met'); + if (timeUntilDeadline <= 0) + break; + + try { + if (await func()) + return; + } catch (e) { + if (e instanceof errors.TimeoutError) + return; + throw e; + } + await new Promise(f => setTimeout(f, delay)); } } @@ -181,3 +186,8 @@ export function errorWithFile(file: string, message: string) { 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`); +} diff --git a/tests/page/locator-query.spec.ts b/tests/page/locator-query.spec.ts index cbfb620456..cd4a1357a1 100644 --- a/tests/page/locator-query.spec.ts +++ b/tests/page/locator-query.spec.ts @@ -47,3 +47,15 @@ it('should throw on capture w/ nth()', async ({page}) => { const e = await page.locator('*css=div >> p').nth(0).click().catch(e => e); expect(e.message).toContain(`Can't query n-th element`); }); + +it('should throw on due to strictness', async ({page}) => { + await page.setContent(`
A
B
`); + const e = await page.locator('div').isVisible().catch(e => e); + expect(e.message).toContain(`strict mode violation`); +}); + +it('should throw on due to strictness 2', async ({page}) => { + await page.setContent(``); + const e = await page.locator('option').evaluate(e => {}).catch(e => e); + expect(e.message).toContain(`strict mode violation`); +}); diff --git a/tests/playwright-test/playwright.expect.misc.spec.ts b/tests/playwright-test/playwright.expect.misc.spec.ts new file mode 100644 index 0000000000..7eb185290f --- /dev/null +++ b/tests/playwright-test/playwright.expect.misc.spec.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect, stripAscii } from './playwright-test-fixtures'; + +test('should support toHaveLength', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + const { test } = pwt; + + test('pass', async ({ page }) => { + await page.setContent(''); + const locator = page.locator('option'); + await expect(locator).toHaveLength(2); + await expect([1, 2]).toHaveLength(2); + }); + `, + }, { workers: 1 }); + expect(result.passed).toBe(1); + expect(result.exitCode).toBe(0); +}); + +test('should support toHaveProp', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + const { test } = pwt; + + test('pass', async ({ page }) => { + await page.setContent('
'); + await page.$eval('div', e => e.foo = { a: 1, b: 'string', c: new Date(1627503992000) }); + const locator = page.locator('div'); + await expect(locator).toHaveProp('foo', { a: 1, b: 'string', c: new Date(1627503992000) }); + }); + + test('fail', async ({ page }) => { + await page.setContent('
'); + await page.$eval('div', e => e.foo = { a: 1, b: 'string', c: new Date(1627503992000) }); + const locator = page.locator('div'); + await expect(locator).toHaveProp('foo', { a: 1, b: 'string', c: new Date(1627503992001) }, { timeout: 1000 }); + }); + `, + }, { workers: 1 }); + const output = stripAscii(result.output); + expect(output).toContain('- "c"'); + expect(result.passed).toBe(1); + expect(result.failed).toBe(1); + expect(result.exitCode).toBe(1); +}); + +test('should support toHaveClass', 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'); + }); + + test('fail', async ({ page }) => { + await page.setContent('
'); + const locator = page.locator('div'); + await expect(locator).toHaveClass('foo', { timeout: 1000 }); + }); + `, + }, { workers: 1 }); + const output = stripAscii(result.output); + expect(output).toContain('expect(locator).toHaveClass'); + expect(output).toContain('Expected substring: \"foo\"'); + expect(result.passed).toBe(1); + expect(result.failed).toBe(1); + expect(result.exitCode).toBe(1); +}); diff --git a/tests/playwright-test/playwright.expect.true.spec.ts b/tests/playwright-test/playwright.expect.true.spec.ts index b6662d8815..5ebb35cc76 100644 --- a/tests/playwright-test/playwright.expect.true.spec.ts +++ b/tests/playwright-test/playwright.expect.true.spec.ts @@ -36,7 +36,7 @@ test('should support toBeChecked', async ({ runInlineTest }) => { test('fail', async ({ page }) => { await page.setContent(''); const locator = page.locator('input'); - await expect(locator).toBeChecked({ timeout: 100 }); + await expect(locator).toBeChecked({ timeout: 1000 }); }); `, }, { workers: 1 }); @@ -72,7 +72,7 @@ test('should support toBeEditable, toBeEnabled, toBeDisabled, toBeEmpty', async }); test('empty input', async ({ page }) => { - await page.setContent(''); + await page.setContent(''); const locator = page.locator('input'); await expect(locator).toBeEmpty(); }); @@ -128,7 +128,7 @@ test('should support toBeVisible, toBeHidden', async ({ runInlineTest }) => { expect(result.exitCode).toBe(0); }); -test('should support toBeFocused', async ({ runInlineTest }) => { +test('should support toBeFocused, toBeSelected', async ({ runInlineTest }) => { const result = await runInlineTest({ 'a.test.ts': ` const { test } = pwt; @@ -139,8 +139,23 @@ test('should support toBeFocused', async ({ runInlineTest }) => { await locator.focus(); await expect(locator).toBeFocused({ timeout: 1000 }); }); + + test('selected', async ({ page }) => { + await page.setContent(''); + const locator = page.locator('option'); + await expect(locator).toBeSelected(); + }); + + test('fail on strict option', async ({ page }) => { + await page.setContent(''); + const locator = page.locator('option'); + await expect(locator).toBeSelected(); + }); `, }, { workers: 1 }); - expect(result.passed).toBe(1); - expect(result.exitCode).toBe(0); + const output = stripAscii(result.output); + expect(output).toContain('strict mode violation'); + expect(result.passed).toBe(2); + expect(result.failed).toBe(1); + expect(result.exitCode).toBe(1); }); diff --git a/types/testExpect.d.ts b/types/testExpect.d.ts index a015649648..c04fbd8e67 100644 --- a/types/testExpect.d.ts +++ b/types/testExpect.d.ts @@ -143,7 +143,22 @@ declare global { * Asserts given DOM is a focused (active) in document. */ toBeFocused(options?: { timeout?: number }): Promise; - } + + /** + * Asserts given select option is selected + */ + toBeSelected(options?: { timeout?: number }): Promise; + + /** + * Asserts JavaScript object that corresponds to the Node has a property with given value. + */ + toHaveProp(name: string, value: any, options?: { timeout?: number }): Promise; + + /** + * Asserts that DOM node has a given CSS class. + */ + toHaveClass(className: string, options?: { timeout?: number }): Promise; + } } }