From d6ec1ae3994f127e38b866a231a34efc6a4cac0d Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 22 Sep 2023 13:56:59 -0700 Subject: [PATCH] chore: document chaining expect.extend (#27262) Fixes https://github.com/microsoft/playwright/issues/15951 --- docs/src/test-configuration-js.md | 100 +++++++----------- packages/playwright/src/matchers/expect.ts | 27 ++--- .../playwright/src/matchers/matcherHint.ts | 27 ++++- .../playwright/src/matchers/toBeTruthy.ts | 10 +- packages/playwright/src/matchers/toEqual.ts | 9 +- .../src/matchers/toMatchSnapshot.ts | 1 - .../playwright/src/matchers/toMatchText.ts | 1 + tests/page/expect-matcher-result.spec.ts | 10 ++ 8 files changed, 99 insertions(+), 86 deletions(-) diff --git a/docs/src/test-configuration-js.md b/docs/src/test-configuration-js.md index 995decb80e..e617915816 100644 --- a/docs/src/test-configuration-js.md +++ b/docs/src/test-configuration-js.md @@ -153,81 +153,57 @@ export default defineConfig({ You can extend Playwright assertions by providing custom matchers. These matchers will be available on the `expect` object. -In this example we add a custom `toBeWithinRange` function in the configuration file. Custom matcher should return a `message` callback and a `pass` flag indicating whether the assertion passed. +In this example we add a custom `toHaveAmount` function. Custom matcher should return a `message` callback and a `pass` flag indicating whether the assertion passed. -```js tab=js-js title="playwright.config.ts" -const { expect, defineConfig } = require('@playwright/test'); +```js title="fixtures.ts" +import { expect as baseExpect } from '@playwright/test'; +import type { Page, Locator } from '@playwright/test'; -expect.extend({ - toBeWithinRange(received, floor, ceiling) { - const pass = received >= floor && received <= ceiling; - if (pass) { - return { - message: () => 'passed', - pass: true, - }; - } else { - return { - message: () => 'failed', - pass: false, - }; +export { test } from '@playwright/test'; + +export const expect = baseExpect.extend({ + async toHaveAmount(locator: Locator, expected: number, options?: { timeout?: number }) { + let pass: boolean; + let matcherResult: any; + try { + await baseExpect(locator).toHaveAttribute('data-amount', String(expected), options); + pass = true; + } catch (e: any) { + matcherResult = e.matcherResult; + pass = false; } + + const message = pass + ? () => this.utils.matcherHint('toHaveAmount', locator, expected, { isNot: this.isNot }) + + '\n\n' + + `Expected: \${this.isNot ? 'not' : ''}\${this.utils.printExpected(expected)}\n` + + (matcherResult ? `Received: ${this.utils.printReceived(matcherResult.actual)}` : '') + : () => this.utils.matcherHint('toHaveAmount', locator, expected, expectOptions) + + '\n\n' + + `Expected: ${this.utils.printExpected(expected)}\n` + + (matcherResult ? `Received: ${this.utils.printReceived(matcherResult.actual)}` : ''); + + return { + message, + pass, + name: 'toHaveAmount', + expected, + actual: matcherResult?.actual, + }; }, }); - -module.exports = defineConfig({}); ``` -```js tab=js-ts title="playwright.config.ts" -import { expect, defineConfig } from '@playwright/test'; - -expect.extend({ - toBeWithinRange(received: number, floor: number, ceiling: number) { - const pass = received >= floor && received <= ceiling; - if (pass) { - return { - message: () => 'passed', - pass: true, - }; - } else { - return { - message: () => 'failed', - pass: false, - }; - } - }, -}); - -export default defineConfig({}); -``` - -Now we can use `toBeWithinRange` in the test. +Now we can use `toHaveAmount` in the test. ```js title="example.spec.ts" -import { test, expect } from '@playwright/test'; +import { test, expect } from './fixtures'; -test('numeric ranges', () => { - expect(100).toBeWithinRange(90, 110); - expect(101).not.toBeWithinRange(0, 100); +test('amount', async () => { + await expect(page.locator('.cart')).toHaveAmount(4); }); ``` :::note Do not confuse Playwright's `expect` with the [`expect` library](https://jestjs.io/docs/expect). The latter is not fully integrated with Playwright test runner, so make sure to use Playwright's own `expect`. ::: - -For TypeScript, also add the following to your [`global.d.ts`](https://www.typescriptlang.org/docs/handbook/declaration-files/templates/global-d-ts.html). If it does not exist, you need to create it inside your repository. Make sure that your `global.d.ts` gets included inside your `tsconfig.json` via the `files`, `include` or `compilerOptions.typeRoots` option so that your IDE will pick it up. - -You don't need it for JavaScript. - -```js title="global.d.ts" -export {}; - -declare global { - namespace PlaywrightTest { - interface Matchers { - toBeWithinRange(a: number, b: number): R; - } - } -} -``` diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index 18d5c6b597..2f916225f0 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -48,7 +48,7 @@ import { import { toMatchSnapshot, toHaveScreenshot, toHaveScreenshotStepTitle } from './toMatchSnapshot'; import type { Expect } from '../../types/test'; import { currentTestInfo, currentExpectTimeout, setCurrentExpectConfigureTimeout } from '../common/globals'; -import { filteredStackTrace, stringifyStackFrames, trimLongString } from '../util'; +import { filteredStackTrace, trimLongString } from '../util'; import { expect as expectLibrary, INVERTED_COLOR, @@ -58,6 +58,7 @@ import { export type { ExpectMatcherContext } from '../common/expectBundle'; import { zones } from 'playwright-core/lib/utils'; import { TestInfoImpl } from '../worker/testInfo'; +import { ExpectError } from './matcherHint'; // #region // Mirrored from https://github.com/facebook/jest/blob/f13abff8df9a0e1148baf3584bcde6d1b479edc7/packages/expect/src/print.ts @@ -263,31 +264,17 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { laxParent: true, }) : undefined; - const reportStepError = (jestError: Error) => { - const message = jestError.message; - if (customMessage) { - const messageLines = message.split('\n'); - const newMessage = [ - customMessage, - '', - ...messageLines, - ].join('\n'); - jestError.message = newMessage; - jestError.stack = jestError.name + ': ' + newMessage + '\n' + stringifyStackFrames(stackFrames).join('\n'); - } - - // Use the exact stack that we entered the matcher with. - jestError.stack = jestError.name + ': ' + jestError.message + '\n' + stringifyStackFrames(stackFrames).join('\n'); + const reportStepError = (jestError: ExpectError) => { + const error = new ExpectError(jestError, customMessage, stackFrames); const serializedError = { - message: jestError.message, - stack: jestError.stack, + message: error.message, + stack: error.stack, }; - step?.complete({ error: serializedError }); if (this._info.isSoft) testInfo._failWithError(serializedError, false /* isHardError */); else - throw jestError; + throw error; }; const finalizer = () => { diff --git a/packages/playwright/src/matchers/matcherHint.ts b/packages/playwright/src/matchers/matcherHint.ts index 83349cae64..298567bfe0 100644 --- a/packages/playwright/src/matchers/matcherHint.ts +++ b/packages/playwright/src/matchers/matcherHint.ts @@ -17,6 +17,8 @@ import { colors } from 'playwright-core/lib/utilsBundle'; import type { ExpectMatcherContext } from './expect'; import type { Locator } from 'playwright-core'; +import { stringifyStackFrames } from '../util'; +import type { StackFrame } from '@protocol/channels'; export function matcherHint(state: ExpectMatcherContext, locator: Locator | undefined, matcherName: string, expression: any, actual: any, matcherOptions: any, timeout?: number) { let header = state.utils.matcherHint(matcherName, expression, actual, matcherOptions).replace(/ \/\/ deep equality/, '') + '\n\n'; @@ -28,11 +30,34 @@ export function matcherHint(state: ExpectMatcherContext, locator: Locator | unde } export type MatcherResult = { - locator?: Locator; name: string; expected: E; message: () => string; pass: boolean; actual?: A; log?: string[]; + timeout?: number; }; + +export class ExpectError extends Error { + matcherResult: { + message: string; + pass: boolean; + name?: string; + expected?: any; + actual?: any; + log?: string[]; + timeout?: number; + }; + constructor(jestError: ExpectError, customMessage: string, stackFrames: StackFrame[]) { + super(''); + // Copy to erase the JestMatcherError constructor name from the console.log(error). + this.name = jestError.name; + this.message = jestError.message; + this.matcherResult = jestError.matcherResult; + + if (customMessage) + this.message = customMessage + '\n\n' + this.message; + this.stack = this.name + ': ' + this.message + '\n' + stringifyStackFrames(stackFrames).join('\n'); + } +} diff --git a/packages/playwright/src/matchers/toBeTruthy.ts b/packages/playwright/src/matchers/toBeTruthy.ts index c7f5a4906e..26f01ad950 100644 --- a/packages/playwright/src/matchers/toBeTruthy.ts +++ b/packages/playwright/src/matchers/toBeTruthy.ts @@ -48,5 +48,13 @@ export async function toBeTruthy( return matches ? `${header}Expected: not ${expected}\nReceived: ${expected}${logText}` : `${header}Expected: ${expected}\nReceived: ${unexpected}${logText}`; }; - return { message, pass: matches, actual, name: matcherName, expected, log }; + return { + message, + pass: matches, + actual, + name: matcherName, + expected, + log, + timeout: timedOut ? timeout : undefined, + }; } diff --git a/packages/playwright/src/matchers/toEqual.ts b/packages/playwright/src/matchers/toEqual.ts index a4bf47030c..c7b99815f9 100644 --- a/packages/playwright/src/matchers/toEqual.ts +++ b/packages/playwright/src/matchers/toEqual.ts @@ -67,5 +67,12 @@ export async function toEqual( // 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, log }; + return { + actual: received, + expected, message, + name: matcherName, + pass, + log, + timeout: timedOut ? timeout : undefined, + }; } diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index c40dd9da54..bddc6d4178 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -160,7 +160,6 @@ class SnapshotHelper { createMatcherResult(message: string, pass: boolean, log?: string[]): ImageMatcherResult { const unfiltered: ImageMatcherResult = { name: this.matcherName, - locator: this.locator, expected: this.snapshotPath, actual: this.actualPath, diff: this.diffPath, diff --git a/packages/playwright/src/matchers/toMatchText.ts b/packages/playwright/src/matchers/toMatchText.ts index 3a5f39b0d9..5e8145db9c 100644 --- a/packages/playwright/src/matchers/toMatchText.ts +++ b/packages/playwright/src/matchers/toMatchText.ts @@ -105,6 +105,7 @@ export async function toMatchText( pass, actual: received, log, + timeout: timedOut ? timeout : undefined, }; } diff --git a/tests/page/expect-matcher-result.spec.ts b/tests/page/expect-matcher-result.spec.ts index f5f4f72579..53b9588c2e 100644 --- a/tests/page/expect-matcher-result.spec.ts +++ b/tests/page/expect-matcher-result.spec.ts @@ -31,6 +31,7 @@ test('toMatchText-based assertions should have matcher result', async ({ page }) name: 'toHaveText', pass: false, log: expect.any(Array), + timeout: 1, }); expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toHaveText(expected) @@ -52,6 +53,7 @@ Call log`); name: 'toHaveText', pass: true, log: expect.any(Array), + timeout: 1, }); expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).not.toHaveText(expected) @@ -77,6 +79,7 @@ test('toBeTruthy-based assertions should have matcher result', async ({ page }) name: 'toBeVisible', pass: false, log: expect.any(Array), + timeout: 1, }); expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toBeVisible() @@ -98,6 +101,7 @@ Call log`); name: 'toBeVisible', pass: true, log: expect.any(Array), + timeout: 1, }); expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).not.toBeVisible() @@ -123,6 +127,7 @@ test('toEqual-based assertions should have matcher result', async ({ page }) => name: 'toHaveCount', pass: false, log: expect.any(Array), + timeout: 1, }); expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toHaveCount(expected) @@ -143,6 +148,7 @@ Call log`); name: 'toHaveCount', pass: true, log: expect.any(Array), + timeout: 1, }); expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).not.toHaveCount(expected) @@ -171,6 +177,7 @@ test('toBeChecked({ checked: false }) should have expected: false', async ({ pag name: 'toBeChecked', pass: false, log: expect.any(Array), + timeout: 1, }); expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toBeChecked() @@ -192,6 +199,7 @@ Call log`); name: 'toBeChecked', pass: true, log: expect.any(Array), + timeout: 1, }); expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).not.toBeChecked() @@ -213,6 +221,7 @@ Call log`); name: 'toBeChecked', pass: false, log: expect.any(Array), + timeout: 1, }); expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toBeChecked({ checked: false }) @@ -234,6 +243,7 @@ Call log`); name: 'toBeChecked', pass: true, log: expect.any(Array), + timeout: 1, }); expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).not.toBeChecked({ checked: false })