From b8dc0b9156ab9fdcf5220416727a8e5ec3f5163b Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 27 Jul 2021 20:26:12 -0700 Subject: [PATCH] feat(expect): implement toMatchText (#7871) --- docs/src/api/class-locator.md | 22 +-- src/client/locator.ts | 9 +- src/test/expect.ts | 47 +----- src/test/{ => matchers}/golden.ts | 4 +- src/test/matchers/toMatchSnapshot.ts | 56 +++++++ src/test/matchers/toMatchText.ts | 140 ++++++++++++++++++ src/test/util.ts | 15 ++ tests/page/locator-misc-2.spec.ts | 22 --- .../playwright.expect.text.spec.ts | 110 ++++++++++++++ types/testExpect.d.ts | 12 +- types/types.d.ts | 34 +---- 11 files changed, 346 insertions(+), 125 deletions(-) rename src/test/{ => matchers}/golden.ts (98%) create mode 100644 src/test/matchers/toMatchSnapshot.ts create mode 100644 src/test/matchers/toMatchText.ts create mode 100644 tests/playwright-test/playwright.expect.text.spec.ts diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 40e0b4b1b7..17250492a6 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -65,7 +65,7 @@ element.click() ``` ```csharp -var element = page.Finder("text=Submit"); +var element = page.Locator("text=Submit"); await element.HoverAsync(); await element.ClickAsync(); ``` @@ -332,7 +332,7 @@ assert tweets.evaluate("node => node.innerText") == "10 retweets" ``` ```csharp -var tweets = page.Finder(".tweet .retweets"); +var tweets = page.Locator(".tweet .retweets"); Assert.Equals("10 retweets", await tweets.EvaluateAsync("node => node.innerText")); ``` @@ -817,7 +817,7 @@ element.press("Enter") ``` ```csharp -var element = page.Finder("input"); +var element = page.Locator("input"); await element.TypeAsync("some text"); await element.PressAsync("Enter"); ``` @@ -856,19 +856,3 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Locator.uncheck.noWaitAfter = %%-input-no-wait-after-%% ### option: Locator.uncheck.timeout = %%-input-timeout-%% ### option: Locator.uncheck.trial = %%-input-trial-%% - -## async method: Locator.waitFor -- returns: <[null]|[ElementHandle]<[HTMLElement]|[SVGElement]>> - -Returns when element specified by selector satisfies [`option: state`] option. Returns `null` if waiting for `hidden` or `detached`. - -Wait for the element to satisfy [`option: state`] option (either appear/disappear from dom, or become -visible/hidden). If at the moment of calling the method it already satisfies the condition, the method -will return immediately. If the selector doesn't satisfy the condition for the [`option: timeout`] milliseconds, the -function will throw. - -This method works across navigations. - -### option: Locator.waitFor.state = %%-wait-for-selector-state-%% -### option: Locator.waitFor.timeout = %%-input-timeout-%% - diff --git a/src/client/locator.ts b/src/client/locator.ts index 7830cdf04f..57712c1813 100644 --- a/src/client/locator.ts +++ b/src/client/locator.ts @@ -22,14 +22,17 @@ import { monotonicTime } from '../utils/utils'; import { ElementHandle } from './elementHandle'; import { Frame } from './frame'; import { FilePayload, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types'; +import { TimeoutSettings } from '../utils/timeoutSettings'; export class Locator implements api.Locator { private _frame: Frame; private _selector: string; private _visibleSelector: string; + private _timeoutSettings: TimeoutSettings; constructor(frame: Frame, selector: string) { this._frame = frame; + this._timeoutSettings = this._frame.page()._timeoutSettings; this._selector = selector; this._visibleSelector = selector + ' >> _visible=true'; } @@ -202,12 +205,6 @@ export class Locator implements api.Locator { return this._frame.uncheck(this._visibleSelector, { strict: true, ...options }); } - waitFor(options: channels.FrameWaitForSelectorOptions & { state: 'attached' | 'visible' }): Promise>; - waitFor(options?: channels.FrameWaitForSelectorOptions): Promise | null>; - async waitFor(options?: channels.FrameWaitForSelectorOptions): Promise | null> { - return this._frame.waitForSelector(this._visibleSelector, { strict: true, ...options }); - } - [(util.inspect as any).custom]() { return this.toString(); } diff --git a/src/test/expect.ts b/src/test/expect.ts index 744ebafd39..32ddf53359 100644 --- a/src/test/expect.ts +++ b/src/test/expect.ts @@ -16,46 +16,9 @@ import type { Expect } from './types'; import expectLibrary from 'expect'; -import { currentTestInfo } from './globals'; -import { compare } from './golden'; +import { toMatchSnapshot } from './matchers/toMatchSnapshot'; +import { toMatchText, toHaveText } from './matchers/toMatchText'; -export const expect: Expect = expectLibrary; - -function toMatchSnapshot(this: ReturnType, received: Buffer | string, nameOrOptions: string | { name: string, threshold?: number }, optOptions: { threshold?: number } = {}) { - let options: { name: string, threshold?: number }; - const testInfo = currentTestInfo(); - if (!testInfo) - throw new Error(`toMatchSnapshot() must be called during the test`); - if (typeof nameOrOptions === 'string') - options = { name: nameOrOptions, ...optOptions }; - else - options = { ...nameOrOptions }; - if (!options.name) - throw new Error(`toMatchSnapshot() requires a "name" parameter`); - - const projectThreshold = testInfo.project.expect?.toMatchSnapshot?.threshold; - if (options.threshold === undefined && projectThreshold !== undefined) - options.threshold = projectThreshold; - - const withNegateComparison = this.isNot; - const { pass, message, expectedPath, actualPath, diffPath, mimeType } = compare( - received, - options.name, - testInfo.snapshotPath, - testInfo.outputPath, - testInfo.config.updateSnapshots, - withNegateComparison, - options - ); - const contentType = mimeType || 'application/octet-stream'; - if (expectedPath) - testInfo.attachments.push({ name: 'expected', contentType, path: expectedPath }); - if (actualPath) - testInfo.attachments.push({ name: 'actual', contentType, path: actualPath }); - if (diffPath) - testInfo.attachments.push({ name: 'diff', contentType, path: diffPath }); - return { pass, message: () => message }; -} - -expectLibrary.extend({ toMatchSnapshot }); -expectLibrary.setState({ expand: false }); \ No newline at end of file +export const expect: Expect = expectLibrary as any; +expectLibrary.setState({ expand: false }); +expectLibrary.extend({ toMatchSnapshot, toMatchText, toHaveText }); diff --git a/src/test/golden.ts b/src/test/matchers/golden.ts similarity index 98% rename from src/test/golden.ts rename to src/test/matchers/golden.ts index ea29ea5bfa..33739d8531 100644 --- a/src/test/golden.ts +++ b/src/test/matchers/golden.ts @@ -21,8 +21,8 @@ import fs from 'fs'; import path from 'path'; import jpeg from 'jpeg-js'; import pixelmatch from 'pixelmatch'; -import { diff_match_patch, DIFF_INSERT, DIFF_DELETE, DIFF_EQUAL } from '../third_party/diff_match_patch'; -import { UpdateSnapshots } from './types'; +import { diff_match_patch, DIFF_INSERT, DIFF_DELETE, DIFF_EQUAL } from '../../third_party/diff_match_patch'; +import { UpdateSnapshots } from '../types'; // Note: we require the pngjs version of pixelmatch to avoid version mismatches. const { PNG } = require(require.resolve('pngjs', { paths: [require.resolve('pixelmatch')] })); diff --git a/src/test/matchers/toMatchSnapshot.ts b/src/test/matchers/toMatchSnapshot.ts new file mode 100644 index 0000000000..d3f06ed7cb --- /dev/null +++ b/src/test/matchers/toMatchSnapshot.ts @@ -0,0 +1,56 @@ +/** + * 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 type { Expect } from '../types'; +import { currentTestInfo } from '../globals'; +import { compare } from './golden'; +import { SyncExpectationResult } from 'expect/build/types'; + +export function toMatchSnapshot(this: ReturnType, received: Buffer | string, nameOrOptions: string | { name: string, threshold?: number }, optOptions: { threshold?: number } = {}): SyncExpectationResult { + let options: { name: string, threshold?: number }; + const testInfo = currentTestInfo(); + if (!testInfo) + throw new Error(`toMatchSnapshot() must be called during the test`); + if (typeof nameOrOptions === 'string') + options = { name: nameOrOptions, ...optOptions }; + else + options = { ...nameOrOptions }; + if (!options.name) + throw new Error(`toMatchSnapshot() requires a "name" parameter`); + + const projectThreshold = testInfo.project.expect?.toMatchSnapshot?.threshold; + if (options.threshold === undefined && projectThreshold !== undefined) + options.threshold = projectThreshold; + + const withNegateComparison = this.isNot; + const { pass, message, expectedPath, actualPath, diffPath, mimeType } = compare( + received, + options.name, + testInfo.snapshotPath, + testInfo.outputPath, + testInfo.config.updateSnapshots, + withNegateComparison, + options + ); + const contentType = mimeType || 'application/octet-stream'; + if (expectedPath) + testInfo.attachments.push({ name: 'expected', contentType, path: expectedPath }); + if (actualPath) + testInfo.attachments.push({ name: 'actual', contentType, path: actualPath }); + if (diffPath) + testInfo.attachments.push({ name: 'diff', contentType, path: diffPath }); + return { pass, message: () => message || '' }; +} diff --git a/src/test/matchers/toMatchText.ts b/src/test/matchers/toMatchText.ts new file mode 100644 index 0000000000..22dc2ceb12 --- /dev/null +++ b/src/test/matchers/toMatchText.ts @@ -0,0 +1,140 @@ +/** + * 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 { + printReceivedStringContainExpectedResult, + printReceivedStringContainExpectedSubstring +} from 'expect/build/print'; + +import { + EXPECTED_COLOR, + getLabelPrinter, + matcherErrorMessage, + matcherHint, MatcherHintOptions, + printExpected, + printReceived, + printWithType, +} from 'jest-matcher-utils'; +import { Locator } from '../../..'; +import { currentTestInfo } from '../globals'; +import type { Expect } from '../types'; +import { monotonicTime, pollUntilDeadline } from '../util'; + +async function toMatchTextImpl( + this: ReturnType, + locator: Locator, + expected: string | RegExp, + exactMatch: boolean, + options: { timeout?: number, useInnerText?: boolean } = {}, +) { + const testInfo = currentTestInfo(); + if (!testInfo) + throw new Error(`toMatchSnapshot() must be called during the test`); + + const matcherName = exactMatch ? 'toHaveText' : 'toMatchText'; + const matcherOptions: MatcherHintOptions = { + isNot: this.isNot, + promise: this.promise, + }; + + if ( + !(typeof expected === 'string') && + !(expected && typeof expected.test === 'function') + ) { + throw new Error( + matcherErrorMessage( + matcherHint(matcherName, undefined, undefined, matcherOptions), + `${EXPECTED_COLOR( + 'expected', + )} value must be a string or regular expression`, + printWithType('Expected', expected, printExpected), + ), + ); + } + + let received: string; + let pass = false; + const timeout = options.timeout === 0 ? 0 : options.timeout || testInfo.timeout; + const deadline = timeout ? monotonicTime() + timeout : 0; + + try { + await pollUntilDeadline(async () => { + received = options?.useInnerText ? await locator.innerText() : await locator.textContent() || ''; + if (exactMatch) + pass = expected === received; + else + pass = typeof expected === 'string' ? received.includes(expected) : new RegExp(expected).test(received); + return pass === !matcherOptions.isNot; + }, deadline, 100); + } catch (e) { + pass = false; + } + + const stringSubstring = exactMatch ? 'string' : 'substring'; + const message = pass + ? () => + typeof expected === 'string' + ? matcherHint(matcherName, undefined, undefined, matcherOptions) + + '\n\n' + + `Expected ${stringSubstring}: not ${printExpected(expected)}\n` + + `Received string: ${printReceivedStringContainExpectedSubstring( + received, + received.indexOf(expected), + expected.length, + )}` + : matcherHint(matcherName, undefined, undefined, matcherOptions) + + '\n\n' + + `Expected pattern: not ${printExpected(expected)}\n` + + `Received string: ${printReceivedStringContainExpectedResult( + received, + typeof expected.exec === 'function' + ? expected.exec(received) + : null, + )}` + : () => { + const labelExpected = `Expected ${typeof expected === 'string' ? stringSubstring : 'pattern' + }`; + const labelReceived = 'Received string'; + const printLabel = getLabelPrinter(labelExpected, labelReceived); + + return ( + matcherHint(matcherName, undefined, undefined, matcherOptions) + + '\n\n' + + `${printLabel(labelExpected)}${printExpected(expected)}\n` + + `${printLabel(labelReceived)}${printReceived(received)}` + ); + }; + + return { message, pass }; +} + +export async function toMatchText( + this: ReturnType, + locator: Locator, + expected: string | RegExp, + options?: { timeout?: number, useInnerText?: boolean }, +) { + return toMatchTextImpl.call(this, locator, expected, false, options); +} + +export async function toHaveText( + this: ReturnType, + locator: Locator, + expected: string, + options?: { timeout?: number, useInnerText?: boolean }, +) { + return toMatchTextImpl.call(this, locator, expected, true, options); +} \ No newline at end of file diff --git a/src/test/util.ts b/src/test/util.ts index e85ed09920..9f1b4a4212 100644 --- a/src/test/util.ts +++ b/src/test/util.ts @@ -18,6 +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'; export class DeadlineRunner { private _timer: NodeJS.Timer | undefined; @@ -69,6 +70,20 @@ export async function raceAgainstDeadline(promise: Promise, deadline: numb return (new DeadlineRunner(promise, deadline)).result; } +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'); + } +} + + export function serializeError(error: Error | any): TestError { if (error instanceof Error) { return { diff --git a/tests/page/locator-misc-2.spec.ts b/tests/page/locator-misc-2.spec.ts index ca17392788..81da5b67d7 100644 --- a/tests/page/locator-misc-2.spec.ts +++ b/tests/page/locator-misc-2.spec.ts @@ -61,28 +61,6 @@ it('should type', async ({ page }) => { expect(await page.$eval('input', input => input.value)).toBe('hello'); }); -it('should wait for visible', async ({ page }) => { - async function giveItAChanceToResolve() { - for (let i = 0; i < 5; i++) - await page.evaluate(() => new Promise(f => requestAnimationFrame(() => requestAnimationFrame(f)))); - } - - await page.setContent(``); - const div = page.locator('div'); - let done = false; - const promise = div.waitFor({ state: 'visible' }).then(() => done = true); - await giveItAChanceToResolve(); - expect(done).toBe(false); - await page.evaluate(() => (window as any).div.style.display = 'block'); - await promise; -}); - -it('should wait for already visible', async ({ page }) => { - await page.setContent(`
content
`); - const div = page.locator('div'); - await div.waitFor({ state: 'visible' }); -}); - it('should take screenshot', async ({ page, server, browserName, headless, isAndroid }) => { it.skip(browserName === 'firefox' && !headless); it.skip(isAndroid, 'Different dpr. Remove after using 1x scale for screenshots.'); diff --git a/tests/playwright-test/playwright.expect.text.spec.ts b/tests/playwright-test/playwright.expect.text.spec.ts new file mode 100644 index 0000000000..5f04475024 --- /dev/null +++ b/tests/playwright-test/playwright.expect.text.spec.ts @@ -0,0 +1,110 @@ +/** + * 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 toMatchText', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + const { test } = pwt; + + test('pass', async ({ page }) => { + await page.setContent('
Text content
'); + const handle = page.locator('#node'); + await expect(handle).toMatchText(/Text/); + }); + + test('fail', async ({ page }) => { + await page.setContent('
Text content
'); + const handle = page.locator('#node'); + await expect(handle).toMatchText(/Text 2/, { timeout: 100 }); + }); + `, + }, { workers: 1 }); + const output = stripAscii(result.output); + expect(output).toContain('Error: expect(received).toMatchText(expected)'); + expect(output).toContain('Expected pattern: /Text 2/'); + expect(output).toContain('Received string: "Text content"'); + expect(output).toContain('expect(handle).toMatchText'); + expect(result.passed).toBe(1); + expect(result.failed).toBe(1); + expect(result.exitCode).toBe(1); +}); + +test('should support toHaveText', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + const { test } = pwt; + + test('pass', async ({ page }) => { + await page.setContent('
Text content
'); + const handle = page.locator('#node'); + await expect(handle).toHaveText('Text content'); + }); + + test('fail', async ({ page }) => { + await page.setContent('
Text content
'); + const handle = page.locator('#node'); + await expect(handle).toHaveText('Text', { timeout: 100 }); + }); + `, + }, { workers: 1 }); + const output = stripAscii(result.output); + expect(output).toContain('Error: expect(received).toHaveText(expected)'); + expect(output).toContain('Expected string: "Text"'); + expect(output).toContain('Received string: "Text content"'); + expect(output).toContain('expect(handle).toHaveText'); + expect(result.passed).toBe(1); + expect(result.failed).toBe(1); + expect(result.exitCode).toBe(1); +}); + +test('should support toMatchText eventually', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + const { test } = pwt; + + test('pass eventually', async ({ page }) => { + await page.setContent('
Text content
'); + const handle = page.locator('#node'); + await Promise.all([ + expect(handle).toMatchText(/Text 2/), + page.waitForTimeout(1000).then(() => handle.evaluate(element => element.textContent = 'Text 2 content')), + ]); + }); + `, + }, { workers: 1 }); + expect(result.passed).toBe(1); + expect(result.failed).toBe(0); + expect(result.exitCode).toBe(0); +}); + +test('should support toMatchText with innerText', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + const { test } = pwt; + + test('pass', async ({ page }) => { + await page.setContent('
Text content
'); + const handle = page.locator('#node'); + await expect(handle).toHaveText('Text content', { useInnerText: true }); + }); + `, + }, { workers: 1 }); + expect(result.passed).toBe(1); + expect(result.exitCode).toBe(0); +}); + diff --git a/types/testExpect.d.ts b/types/testExpect.d.ts index 59541a7068..29f91b11bf 100644 --- a/types/testExpect.d.ts +++ b/types/testExpect.d.ts @@ -35,7 +35,7 @@ type OverriddenExpectProperties = 'rejects' | 'toMatchInlineSnapshot' | 'toThrowErrorMatchingInlineSnapshot' | -'toMatchSnapshot' | +'toMatchSnapshot' | 'toThrowErrorMatchingSnapshot'; declare global { @@ -68,6 +68,16 @@ declare global { toMatchSnapshot(name: string, options?: { threshold?: number }): R; + + /** + * Asserts element's exact text content. + */ + toHaveText(expected: string, options?: { timeout?: number, useInnerText?: boolean }): Promise; + + /** + * Asserts element's text content matches given pattern or contains given substring. + */ + toMatchText(expected: string | RegExp, options?: { timeout?: number, useInnerText?: boolean }): Promise; } } } diff --git a/types/types.d.ts b/types/types.d.ts index 9a81c8e95b..f3f0c918af 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -8036,39 +8036,7 @@ export interface Locator { * `false`. Useful to wait until the element is ready for the action without performing it. */ trial?: boolean; - }): Promise; - - /** - * Returns when element specified by selector satisfies `state` option. Returns `null` if waiting for `hidden` or - * `detached`. - * - * Wait for the element to satisfy `state` option (either appear/disappear from dom, or become visible/hidden). If at the - * moment of calling the method it already satisfies the condition, the method will return immediately. If the selector - * doesn't satisfy the condition for the `timeout` milliseconds, the function will throw. - * - * This method works across navigations. - * @param options - */ - waitFor(options?: { - /** - * Defaults to `'visible'`. Can be either: - * - `'attached'` - wait for element to be present in DOM. - * - `'detached'` - wait for element to not be present in DOM. - * - `'visible'` - wait for element to have non-empty bounding box and no `visibility:hidden`. Note that element without - * any content or with `display:none` has an empty bounding box and is not considered visible. - * - `'hidden'` - wait for element to be either detached from DOM, or have an empty bounding box or `visibility:hidden`. - * This is opposite to the `'visible'` option. - */ - state?: "attached"|"detached"|"visible"|"hidden"; - - /** - * Maximum time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be changed by - * using the - * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) - * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. - */ - timeout?: number; - }): Promise>;} + }): Promise;} /** * BrowserType provides methods to launch a specific browser instance or connect to an existing one. The following is a