From ce009c75f1402dfabdc50770b2eb53d003f50c2b Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 9 Apr 2026 16:29:45 +0100 Subject: [PATCH] chore: decouple expect matchers from parent src/ imports Reverse the dependency so matchers are self-contained: - Add ExpectTestInfo/ExpectStepInfo/ExpectStep interfaces in matchers/expect.ts - Inject config via setExpectConfig() from workerMain.ts - Move expectUtils.ts from @utils to @isomorphic - Move formatting helpers from expectUtils to matcherHint.ts - Inline addSuffixToFilePath and fileExistsAsync into matchers - Remove ../globals.ts and ../util.ts from matchers/DEPS.list - Move _deadlineForMatcher logic into matchers, expose _deadline() on TestInfo - Combine _hasNonRetriableError into _failWithError(error, 'shouldNotRetry') --- .../src/server/recorder/recorderRunner.ts | 2 +- .../src/server/utils/expectUtils.ts | 177 ------------------ .../src/server/utils/fileUtils.ts | 6 + .../playwright-core/src/server/utils/index.ts | 1 - .../src/utils/isomorphic/expectUtils.ts | 29 +++ .../src/utils/isomorphic/index.ts | 1 + packages/playwright/src/matchers/DEPS.list | 2 - packages/playwright/src/matchers/expect.ts | 100 ++++++++-- .../playwright/src/matchers/matcherHint.ts | 128 +++++++++++++ packages/playwright/src/matchers/matchers.ts | 38 ++-- .../playwright/src/matchers/toBeTruthy.ts | 4 +- packages/playwright/src/matchers/toEqual.ts | 3 +- packages/playwright/src/matchers/toHaveURL.ts | 3 +- .../src/matchers/toMatchAriaSnapshot.ts | 22 +-- .../src/matchers/toMatchSnapshot.ts | 41 ++-- .../playwright/src/matchers/toMatchText.ts | 4 +- packages/playwright/src/worker/testInfo.ts | 15 +- packages/playwright/src/worker/workerMain.ts | 15 +- utils/build/build.js | 1 + 19 files changed, 330 insertions(+), 262 deletions(-) delete mode 100644 packages/playwright-core/src/server/utils/expectUtils.ts create mode 100644 packages/playwright-core/src/utils/isomorphic/expectUtils.ts diff --git a/packages/playwright-core/src/server/recorder/recorderRunner.ts b/packages/playwright-core/src/server/recorder/recorderRunner.ts index 17f0dd1265dd2..1123b63fa48c7 100644 --- a/packages/playwright-core/src/server/recorder/recorderRunner.ts +++ b/packages/playwright-core/src/server/recorder/recorderRunner.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { serializeExpectedTextValues } from '@utils/expectUtils'; +import { serializeExpectedTextValues } from '@isomorphic/expectUtils'; import { toKeyboardModifiers } from '../codegen/language'; import { buildFullSelector, mainFrameForAction } from './recorderUtils'; import { Progress } from '../progress'; diff --git a/packages/playwright-core/src/server/utils/expectUtils.ts b/packages/playwright-core/src/server/utils/expectUtils.ts deleted file mode 100644 index d06b901f1acba..0000000000000 --- a/packages/playwright-core/src/server/utils/expectUtils.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * 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 colors from 'colors/safe'; -import { isRegExp, isString } from '@isomorphic/rtti'; -import type { ExpectedTextValue } from '@protocol/channels'; - -export interface InternalMatcherUtils { - printDiffOrStringify(expected: unknown, received: unknown, expectedLabel: string, receivedLabel: string, expand: boolean): string; - printExpected(value: unknown): string; - printReceived(object: unknown): string; - DIM_COLOR(text: string): string; - RECEIVED_COLOR(text: string): string; - INVERTED_COLOR(text: string): string; - EXPECTED_COLOR(text: string): string; -} - -export function serializeExpectedTextValues(items: (string | RegExp)[], options: { matchSubstring?: boolean, normalizeWhiteSpace?: boolean, ignoreCase?: boolean } = {}): ExpectedTextValue[] { - return items.map(i => ({ - string: isString(i) ? i : undefined, - regexSource: isRegExp(i) ? i.source : undefined, - regexFlags: isRegExp(i) ? i.flags : undefined, - matchSubstring: options.matchSubstring, - ignoreCase: options.ignoreCase, - normalizeWhiteSpace: options.normalizeWhiteSpace, - })); -} - -// #region -// Mirrored from https://github.com/facebook/jest/blob/f13abff8df9a0e1148baf3584bcde6d1b479edc7/packages/expect/src/print.ts with minor modifications. -/** - * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. - * - * This source code is licensed under the MIT license found here - * https://github.com/facebook/jest/blob/1547740bbc26400d69f4576bf35645163e942829/LICENSE - */ - -// Format substring but do not enclose in double quote marks. -// The replacement is compatible with pretty-format package. -const printSubstring = (val: string): string => val.replace(/"|\\/g, '\\$&'); - -export const printReceivedStringContainExpectedSubstring = ( - utils: InternalMatcherUtils, - received: string, - start: number, - length: number, // not end -): string => - utils.RECEIVED_COLOR( - '"' + - printSubstring(received.slice(0, start)) + - utils.INVERTED_COLOR(printSubstring(received.slice(start, start + length))) + - printSubstring(received.slice(start + length)) + - '"', - ); - -export const printReceivedStringContainExpectedResult = ( - utils: InternalMatcherUtils, - received: string, - result: RegExpExecArray | null, -): string => - result === null - ? utils.printReceived(received) - : printReceivedStringContainExpectedSubstring( - utils, - received, - result.index, - result[0].length, - ); - -// #endregion - -type MatcherMessageDetails = { - promise?: '' | 'rejects' | 'resolves'; - isNot?: boolean; - receiver?: string; // Assuming 'locator' when locator is provided, 'page' otherwise. - matcherName: string; - expectation: string; - locator?: string; - printedExpected?: string; - printedReceived?: string; - printedDiff?: string; - timedOut?: boolean; - timeout?: number; - errorMessage?: string; - log?: string[]; -}; - -export function formatMatcherMessage(utils: InternalMatcherUtils, details: MatcherMessageDetails) { - const receiver = details.receiver ?? (details.locator ? 'locator' : 'page'); - let message = utils.DIM_COLOR('expect(') + utils.RECEIVED_COLOR(receiver) - + utils.DIM_COLOR(')' + (details.promise ? '.' + details.promise : '') + (details.isNot ? '.not' : '') + '.') - + details.matcherName - + utils.DIM_COLOR('(') + utils.EXPECTED_COLOR(details.expectation) + utils.DIM_COLOR(')') - + ' failed\n\n'; - - // Sometimes diff is actually expected + received. Turn it into two lines to - // simplify alignment logic. - const diffLines = details.printedDiff?.split('\n'); - if (diffLines?.length === 2) { - details.printedExpected = diffLines[0]; - details.printedReceived = diffLines[1]; - details.printedDiff = undefined; - } - - const align = !details.errorMessage && details.printedExpected?.startsWith('Expected:') - && (!details.printedReceived || details.printedReceived.startsWith('Received:')); - if (details.locator) - message += `Locator: ${align ? ' ' : ''}${details.locator}\n`; - if (details.printedExpected) - message += details.printedExpected + '\n'; - if (details.printedReceived) - message += details.printedReceived + '\n'; - if (details.timedOut && details.timeout) - message += `Timeout: ${align ? ' ' : ''}${details.timeout}ms\n`; - if (details.printedDiff) - message += details.printedDiff + '\n'; - if (details.errorMessage) { - message += details.errorMessage; - if (!details.errorMessage.endsWith('\n')) - message += '\n'; - } - message += callLogText(utils, details.log); - return message; -} - -export const callLogText = (utils: InternalMatcherUtils, log: string[] | undefined) => { - if (!log || !log.some(l => !!l)) - return ''; - return ` -Call log: -${utils.DIM_COLOR(log.join('\n'))} -`; -}; - - -function printValue(value: unknown): string { - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -function printReceived(value: unknown): string { - return colors.red(printValue(value)); -} - -function printExpected(value: unknown): string { - return colors.green(printValue(value)); -} - -export const simpleMatcherUtils: InternalMatcherUtils = { - DIM_COLOR: colors.dim, - RECEIVED_COLOR: colors.red, - EXPECTED_COLOR: colors.green, - INVERTED_COLOR: colors.inverse, - printReceived, - printExpected, - printDiffOrStringify: (expected: unknown, received: unknown, expectedLabel: string, receivedLabel: string) => { - const maxLength = Math.max(expectedLabel.length, receivedLabel.length) + 2; - return `${expectedLabel}: `.padEnd(maxLength) + printExpected(expected) + `\n` + - `${receivedLabel}: `.padEnd(maxLength) + printReceived(received); - }, -}; diff --git a/packages/playwright-core/src/server/utils/fileUtils.ts b/packages/playwright-core/src/server/utils/fileUtils.ts index 6516c8b3ca78b..9a4e60922f5d8 100644 --- a/packages/playwright-core/src/server/utils/fileUtils.ts +++ b/packages/playwright-core/src/server/utils/fileUtils.ts @@ -54,6 +54,12 @@ export async function copyFileAndMakeWritable(from: string, to: string) { await fs.promises.chmod(to, 0o664); } +export function addSuffixToFilePath(filePath: string, suffix: string): string { + const ext = path.extname(filePath); + const base = filePath.substring(0, filePath.length - ext.length); + return base + suffix + ext; +} + export function sanitizeForFilePath(s: string) { return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-'); } diff --git a/packages/playwright-core/src/server/utils/index.ts b/packages/playwright-core/src/server/utils/index.ts index 05549190e1ed9..be7b31b1f0c53 100644 --- a/packages/playwright-core/src/server/utils/index.ts +++ b/packages/playwright-core/src/server/utils/index.ts @@ -21,7 +21,6 @@ export * from './debug'; export * from './debugLogger'; export * from './env'; export * from './eventsHelper'; -export * from './expectUtils'; export * from './fileUtils'; export * from './hostPlatform'; export * from './httpServer'; diff --git a/packages/playwright-core/src/utils/isomorphic/expectUtils.ts b/packages/playwright-core/src/utils/isomorphic/expectUtils.ts new file mode 100644 index 0000000000000..e7be8db8528aa --- /dev/null +++ b/packages/playwright-core/src/utils/isomorphic/expectUtils.ts @@ -0,0 +1,29 @@ +/** + * 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 { isRegExp, isString } from './rtti'; +import type { ExpectedTextValue } from '@protocol/channels'; + +export function serializeExpectedTextValues(items: (string | RegExp)[], options: { matchSubstring?: boolean, normalizeWhiteSpace?: boolean, ignoreCase?: boolean } = {}): ExpectedTextValue[] { + return items.map(i => ({ + string: isString(i) ? i : undefined, + regexSource: isRegExp(i) ? i.source : undefined, + regexFlags: isRegExp(i) ? i.flags : undefined, + matchSubstring: options.matchSubstring, + ignoreCase: options.ignoreCase, + normalizeWhiteSpace: options.normalizeWhiteSpace, + })); +} diff --git a/packages/playwright-core/src/utils/isomorphic/index.ts b/packages/playwright-core/src/utils/isomorphic/index.ts index ae297445717b1..23884d42e3ec4 100644 --- a/packages/playwright-core/src/utils/isomorphic/index.ts +++ b/packages/playwright-core/src/utils/isomorphic/index.ts @@ -15,6 +15,7 @@ */ export * from './ariaSnapshot'; +export * from './expectUtils'; export * from './assert'; export * from './colors'; export * from './headers'; diff --git a/packages/playwright/src/matchers/DEPS.list b/packages/playwright/src/matchers/DEPS.list index dd0868e2d051a..d7b1f89784ec8 100644 --- a/packages/playwright/src/matchers/DEPS.list +++ b/packages/playwright/src/matchers/DEPS.list @@ -1,7 +1,5 @@ [*] @isomorphic/** @utils/** -../globals.ts ../package.ts -../util.ts node_modules/colors/safe diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index 43dedbc53a8fc..029b50a97cdce 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -14,16 +14,17 @@ * limitations under the License. */ -import { captureRawStack } from '@isomorphic/stackTrace'; +import path from 'path'; + +import { parseStackFrame, captureRawStack } from '@isomorphic/stackTrace'; import { escapeWithQuotes, isString } from '@isomorphic/stringUtils'; import { pollAgainstDeadline } from '@isomorphic/timeoutRunner'; -import { createGuid } from '@utils/crypto'; import { currentZone } from '@utils/zones'; import { ExpectError, isJestError } from './matcherHint'; import { computeMatcherTitleSuffix, - defaultDeadlineForMatcher, + deadlineForMatcher, toBeAttached, toBeChecked, toBeDisabled, @@ -59,12 +60,77 @@ import { toHaveScreenshot, toMatchSnapshot } from './toMatchSnapshot'; import { expect as expectLibrary, } from './expectBundle'; -import * as globals from '../globals'; -import { filteredStackTrace } from '../util'; import type { ExpectMatcherStateInternal } from './matchers'; import type { Expect } from '../../types/test'; -import type { TestInfoImpl, TestStepInfoImpl } from '../worker/testInfo'; +import type { StackFrame } from '@protocol/channels'; + +export interface ExpectStepInfo { + _attachToStep(attachment: { name: string; contentType: string; path?: string; body?: string | Buffer }): void; +} + +export interface ExpectStep { + complete(result: { error?: Error | unknown, suggestedRebaseline?: string }): void; + info: ExpectStepInfo; +} + +export interface ExpectTestInfo { + _addStep(data: { + category: 'expect'; + apiName: string; + title: string; + shortTitle: string; + params?: Record; + infectParentStepsWithError?: boolean; + }): ExpectStep; + _deadline(): { deadline: number; timeout: number }; + _failWithError(error: Error | unknown, shouldNotRetry?: 'shouldNotRetry'): void; + _resolveSnapshotPaths(kind: 'snapshot' | 'screenshot' | 'aria', name: string | string[] | undefined, updateSnapshotIndex: 'updateSnapshotIndex' | 'dontUpdateSnapshotIndex', anonymousExtension?: string): { absoluteSnapshotPath: string; relativeOutputPath: string }; + _getOutputPath(...pathSegments: string[]): string; +} + +export type ExpectConfig = { + testInfo: ExpectTestInfo | null; + filteredStackTrace: (rawStack: string[]) => StackFrame[]; + ignoreSnapshots: boolean; + updateSnapshots: 'all' | 'changed' | 'missing' | 'none'; + timeout?: number; + toHaveScreenshot?: { + threshold?: number; + maxDiffPixels?: number; + maxDiffPixelRatio?: number; + animations?: 'allow' | 'disabled'; + caret?: 'hide' | 'initial'; + scale?: 'css' | 'device'; + stylePath?: string | string[]; + pathTemplate?: string; + _comparator?: string; + }; + toMatchSnapshot?: { + threshold?: number; + maxDiffPixels?: number; + maxDiffPixelRatio?: number; + }; + toMatchAriaSnapshot?: { + pathTemplate?: string; + children?: 'contain' | 'equal' | 'deep-equal'; + }; + toPass?: { timeout?: number; intervals?: number[] }; +}; + +function unfilteredStackTrace(rawStack: string[]): StackFrame[] { + return rawStack.map(frame => parseStackFrame(frame, path.sep, !!process.env.PWDEBUGIMPL)).filter(f => !!f); +} + +let _expectConfig: ExpectConfig = { testInfo: null, filteredStackTrace: unfilteredStackTrace, ignoreSnapshots: false, updateSnapshots: 'missing' }; + +export function setExpectConfig(config: ExpectConfig) { + _expectConfig = config; +} + +export function expectConfig(): ExpectConfig { + return _expectConfig; +} type ExpectMessage = string | { message?: string }; @@ -78,6 +144,8 @@ function qualifiedMatcherName(qualifier: string[], matcherName: string) { return qualifier.join(':') + '$' + matcherName; } +let lastExtendId = 0; + function createExpect(info: ExpectMetaInfo, prefix: string[], userMatchers: Record) { const expectInstance: Expect<{}> = new Proxy(expectLibrary, { apply: function(target: any, thisArg: any, argumentsList: [unknown, ExpectMessage?]) { @@ -98,7 +166,7 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], userMatchers: Reco if (property === 'extend') { return (matchers: any) => { - const qualifier = [...prefix, createGuid()]; + const qualifier = [...prefix, String(++lastExtendId)]; const wrappedMatchers: any = {}; for (const [name, matcher] of Object.entries(matchers)) { @@ -156,8 +224,8 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], userMatchers: Reco // Rely on sync call sequence to seed each matcher call with the context. type MatcherCallContext = { expectInfo: ExpectMetaInfo; - testInfo: TestInfoImpl | null; - step?: TestStepInfoImpl; + testInfo: ExpectTestInfo | null; + step?: ExpectStepInfo; }; let matcherCallContext: MatcherCallContext | undefined; @@ -182,7 +250,7 @@ function wrapPlaywrightMatcherToPassNiceThis(matcher: any) { return function(this: any, ...args: any[]) { const { isNot, promise, utils } = this; const context = takeMatcherCallContext(); - const timeout = context?.expectInfo.timeout ?? context?.testInfo?._projectInternal?.expect?.timeout ?? defaultExpectTimeout; + const timeout = context?.expectInfo.timeout ?? expectConfig().timeout ?? defaultExpectTimeout; const newThis: ExpectMatcherStateInternal = { isNot, promise, @@ -295,7 +363,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { matcher = (...args: any[]) => pollMatcher(resolvedMatcherName, this._info, this._prefix, ...args); } return (...args: any[]) => { - const testInfo = globals.currentTestInfo(); + const testInfo = expectConfig().testInfo; setMatcherCallContext({ expectInfo: this._info, testInfo }); if (!testInfo) return matcher.call(target, ...args); @@ -309,7 +377,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { // This looks like it is unnecessary, but it isn't - we need to filter // out all the frames that belong to the test runner from caught runtime errors. - const stackFrames = filteredStackTrace(captureRawStack()); + const stackFrames = expectConfig().filteredStackTrace(captureRawStack()); // toPass and poll matchers can contain other steps, expects and API calls, // so they behave like a retriable step. @@ -363,13 +431,13 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { } async function pollMatcher(qualifiedMatcherName: string, info: ExpectMetaInfo, prefix: string[], ...args: any[]) { - const testInfo = globals.currentTestInfo(); + const testInfo = expectConfig().testInfo; const poll = info.poll!; - const timeout = poll.timeout ?? info.timeout ?? testInfo?._projectInternal?.expect?.timeout ?? defaultExpectTimeout; - const { deadline, timeoutMessage } = testInfo ? testInfo._deadlineForMatcher(timeout) : defaultDeadlineForMatcher(timeout); + const timeout = poll.timeout ?? info.timeout ?? expectConfig().timeout ?? defaultExpectTimeout; + const { deadline, timeoutMessage } = deadlineForMatcher(testInfo, timeout); const result = await pollAgainstDeadline(async () => { - if (testInfo && globals.currentTestInfo() !== testInfo) + if (testInfo && expectConfig().testInfo !== testInfo) return { continuePolling: false, result: undefined }; const innerInfo: ExpectMetaInfo = { diff --git a/packages/playwright/src/matchers/matcherHint.ts b/packages/playwright/src/matchers/matcherHint.ts index 9bb3ccae6d76d..5f72da4c1c7a1 100644 --- a/packages/playwright/src/matchers/matcherHint.ts +++ b/packages/playwright/src/matchers/matcherHint.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import util from 'util'; + import { stringifyStackFrames } from '@isomorphic/stackTrace'; import type { StackFrame } from '@protocol/channels'; @@ -56,3 +58,129 @@ export class ExpectError extends Error { export function isJestError(e: unknown): e is JestError { return e instanceof Error && 'matcherResult' in e && !!e.matcherResult; } + +export function expectTypes(receiver: any, types: ('APIResponse' | 'Page' | 'Locator')[], matcherName: string) { + if (typeof receiver !== 'object' || !types.includes(receiver._apiName)) { + const receiverString = typeof receiver === 'object' && receiver !== null ? `${receiver.constructor.name} ${util.inspect(receiver)}` : String(receiver); + const commaSeparated = types.slice(); + const lastType = commaSeparated.pop(); + const typesString = commaSeparated.length ? commaSeparated.join(', ') + ' or ' + lastType : lastType; + throw new Error(`${matcherName} can be only used with ${typesString} object${types.length > 1 ? 's' : ''}, was called with ${receiverString}`); + } +} + +export interface InternalMatcherUtils { + printDiffOrStringify(expected: unknown, received: unknown, expectedLabel: string, receivedLabel: string, expand: boolean): string; + printExpected(value: unknown): string; + printReceived(object: unknown): string; + DIM_COLOR(text: string): string; + RECEIVED_COLOR(text: string): string; + INVERTED_COLOR(text: string): string; + EXPECTED_COLOR(text: string): string; +} + +// #region +// Mirrored from https://github.com/facebook/jest/blob/f13abff8df9a0e1148baf3584bcde6d1b479edc7/packages/expect/src/print.ts with minor modifications. +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found here + * https://github.com/facebook/jest/blob/1547740bbc26400d69f4576bf35645163e942829/LICENSE + */ + +// Format substring but do not enclose in double quote marks. +// The replacement is compatible with pretty-format package. +const printSubstring = (val: string): string => val.replace(/"|\\/g, '\\$&'); + +export const printReceivedStringContainExpectedSubstring = ( + utils: InternalMatcherUtils, + received: string, + start: number, + length: number, // not end +): string => + utils.RECEIVED_COLOR( + '"' + + printSubstring(received.slice(0, start)) + + utils.INVERTED_COLOR(printSubstring(received.slice(start, start + length))) + + printSubstring(received.slice(start + length)) + + '"', + ); + +export const printReceivedStringContainExpectedResult = ( + utils: InternalMatcherUtils, + received: string, + result: RegExpExecArray | null, +): string => + result === null + ? utils.printReceived(received) + : printReceivedStringContainExpectedSubstring( + utils, + received, + result.index, + result[0].length, + ); + +// #endregion + +type MatcherMessageDetails = { + promise?: '' | 'rejects' | 'resolves'; + isNot?: boolean; + receiver?: string; // Assuming 'locator' when locator is provided, 'page' otherwise. + matcherName: string; + expectation: string; + locator?: string; + printedExpected?: string; + printedReceived?: string; + printedDiff?: string; + timedOut?: boolean; + timeout?: number; + errorMessage?: string; + log?: string[]; +}; + +export function formatMatcherMessage(utils: InternalMatcherUtils, details: MatcherMessageDetails) { + const receiver = details.receiver ?? (details.locator ? 'locator' : 'page'); + let message = utils.DIM_COLOR('expect(') + utils.RECEIVED_COLOR(receiver) + + utils.DIM_COLOR(')' + (details.promise ? '.' + details.promise : '') + (details.isNot ? '.not' : '') + '.') + + details.matcherName + + utils.DIM_COLOR('(') + utils.EXPECTED_COLOR(details.expectation) + utils.DIM_COLOR(')') + + ' failed\n\n'; + + // Sometimes diff is actually expected + received. Turn it into two lines to + // simplify alignment logic. + const diffLines = details.printedDiff?.split('\n'); + if (diffLines?.length === 2) { + details.printedExpected = diffLines[0]; + details.printedReceived = diffLines[1]; + details.printedDiff = undefined; + } + + const align = !details.errorMessage && details.printedExpected?.startsWith('Expected:') + && (!details.printedReceived || details.printedReceived.startsWith('Received:')); + if (details.locator) + message += `Locator: ${align ? ' ' : ''}${details.locator}\n`; + if (details.printedExpected) + message += details.printedExpected + '\n'; + if (details.printedReceived) + message += details.printedReceived + '\n'; + if (details.timedOut && details.timeout) + message += `Timeout: ${align ? ' ' : ''}${details.timeout}ms\n`; + if (details.printedDiff) + message += details.printedDiff + '\n'; + if (details.errorMessage) { + message += details.errorMessage; + if (!details.errorMessage.endsWith('\n')) + message += '\n'; + } + message += callLogText(utils, details.log); + return message; +} + +export const callLogText = (utils: InternalMatcherUtils, log: string[] | undefined) => { + if (!log || !log.some(l => !!l)) + return ''; + return ` +Call log: +${utils.DIM_COLOR(log.join('\n'))} +`; +}; diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 4ee6b929711da..d6236cb0eee00 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -21,29 +21,27 @@ import { isRegExp } from '@isomorphic/rtti'; import { isString } from '@isomorphic/stringUtils'; import { pollAgainstDeadline } from '@isomorphic/timeoutRunner'; import { constructURLBasedOnBaseURL, isURLPattern } from '@isomorphic/urlMatch'; -import { formatMatcherMessage, serializeExpectedTextValues } from '@utils/expectUtils'; +import { serializeExpectedTextValues } from '@isomorphic/expectUtils'; import { monotonicTime } from '@isomorphic/index'; -import { expectTypes } from '../util'; +import { expectTypes, formatMatcherMessage, MatcherResult } from './matcherHint'; import { toBeTruthy } from './toBeTruthy'; import { toEqual } from './toEqual'; import { toHaveURLWithPredicate } from './toHaveURL'; import { toMatchText } from './toMatchText'; import { toHaveScreenshotStepTitle } from './toMatchSnapshot'; -import * as globals from '../globals'; -import { MatcherResult } from './matcherHint'; -import { takeFirst } from '../util'; +import { expectConfig } from './expect'; import type { ExpectMatcherState } from '../../types/test'; -import type { TestStepInfoImpl } from '../worker/testInfo'; +import type { ExpectStepInfo, ExpectTestInfo } from './expect'; +import type { InternalMatcherUtils } from './matcherHint'; import type { APIResponse, Locator, Frame, Page } from 'playwright-core'; import type { FrameExpectParams } from 'playwright-core/lib/client/types'; import type { ExpectMatcherUtils } from '../../types/test'; -import type { InternalMatcherUtils } from '@utils/expectUtils'; import type { URLPattern } from '@isomorphic/urlMatch'; export type ExpectMatcherStateInternal = Omit & { - _stepInfo?: TestStepInfoImpl; + _stepInfo?: ExpectStepInfo; utils: ExpectMatcherUtils & InternalMatcherUtils; }; @@ -483,13 +481,13 @@ export async function toPass( timeout?: number, } = {}, ) { - const testInfo = globals.currentTestInfo(); - const timeout = takeFirst(options.timeout, testInfo?._projectInternal.expect?.toPass?.timeout, 0); - const intervals = takeFirst(options.intervals, testInfo?._projectInternal.expect?.toPass?.intervals, [100, 250, 500, 1000]); + const testInfo = expectConfig().testInfo; + const timeout = options.timeout ?? expectConfig().toPass?.timeout ?? 0; + const intervals = options.intervals ?? expectConfig().toPass?.intervals ?? [100, 250, 500, 1000]; - const { deadline, timeoutMessage } = testInfo ? testInfo._deadlineForMatcher(timeout) : defaultDeadlineForMatcher(timeout); + const { deadline, timeoutMessage } = deadlineForMatcher(testInfo, timeout); const result = await pollAgainstDeadline(async () => { - if (testInfo && globals.currentTestInfo() !== testInfo) + if (testInfo && expectConfig().testInfo !== testInfo) return { continuePolling: false, result: undefined }; try { await callback(); @@ -525,6 +523,16 @@ export function computeMatcherTitleSuffix(matcherName: string, receiver: any, ar return {}; } -export function defaultDeadlineForMatcher(timeout: number): { deadline: any; timeoutMessage: any; } { - return { deadline: (timeout ? monotonicTime() + timeout : 0), timeoutMessage: `Timeout ${timeout}ms exceeded while waiting on the predicate` }; +export function deadlineForMatcher(testInfo: ExpectTestInfo | null, timeout: number): { deadline: number; timeoutMessage: string } { + const startTime = monotonicTime(); + const matcherDeadline = timeout ? startTime + timeout : 0; + const matcherMessage = `Timeout ${timeout}ms exceeded while waiting on the predicate`; + if (!testInfo) + return { deadline: matcherDeadline, timeoutMessage: matcherMessage }; + const { deadline: testDeadline, timeout: testTimeout } = testInfo._deadline(); + const effectiveTestDeadline = testDeadline - 250; + const testMessage = `Test timeout of ${testTimeout}ms exceeded`; + if (!matcherDeadline) + return { deadline: effectiveTestDeadline, timeoutMessage: testMessage }; + return { deadline: Math.min(effectiveTestDeadline, matcherDeadline), timeoutMessage: effectiveTestDeadline < matcherDeadline ? testMessage : matcherMessage }; } diff --git a/packages/playwright/src/matchers/toBeTruthy.ts b/packages/playwright/src/matchers/toBeTruthy.ts index 9c22fe2a0fd83..74b9132fe10c4 100644 --- a/packages/playwright/src/matchers/toBeTruthy.ts +++ b/packages/playwright/src/matchers/toBeTruthy.ts @@ -14,9 +14,7 @@ * limitations under the License. */ -import { formatMatcherMessage } from '@utils/expectUtils'; - -import { expectTypes } from '../util'; +import { expectTypes, formatMatcherMessage } from './matcherHint'; import type { MatcherResult } from './matcherHint'; import type { Locator } from 'playwright-core'; diff --git a/packages/playwright/src/matchers/toEqual.ts b/packages/playwright/src/matchers/toEqual.ts index a49473a85c9d0..01fc9414ad14c 100644 --- a/packages/playwright/src/matchers/toEqual.ts +++ b/packages/playwright/src/matchers/toEqual.ts @@ -15,9 +15,8 @@ */ import { isRegExp } from '@isomorphic/rtti'; -import { formatMatcherMessage } from '@utils/expectUtils'; -import { expectTypes } from '../util'; +import { expectTypes, formatMatcherMessage } from './matcherHint'; import type { MatcherResult } from './matcherHint'; import type { Locator } from 'playwright-core'; diff --git a/packages/playwright/src/matchers/toHaveURL.ts b/packages/playwright/src/matchers/toHaveURL.ts index 5395d27d035ad..7273582bc6614 100644 --- a/packages/playwright/src/matchers/toHaveURL.ts +++ b/packages/playwright/src/matchers/toHaveURL.ts @@ -15,7 +15,8 @@ */ import { urlMatches } from '@isomorphic/urlMatch'; -import { formatMatcherMessage, printReceivedStringContainExpectedResult } from '@utils/expectUtils'; + +import { formatMatcherMessage, printReceivedStringContainExpectedResult } from './matcherHint'; import type { MatcherResult } from './matcherHint'; import type { Page } from 'playwright-core'; diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index b6497ef5af26d..4dcf7dc45948e 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -19,10 +19,10 @@ import fs from 'fs'; import path from 'path'; import { escapeTemplateString, isString } from '@isomorphic/stringUtils'; -import { formatMatcherMessage, printReceivedStringContainExpectedSubstring } from '@utils/expectUtils'; +import { existsAsync } from '@utils/fileUtils'; -import { expectTypes, fileExistsAsync } from '../util'; -import * as globals from '../globals'; +import { expectTypes, formatMatcherMessage, printReceivedStringContainExpectedSubstring } from './matcherHint'; +import { expectConfig } from './expect'; import type { MatcherResult } from './matcherHint'; import type { ExpectMatcherStateInternal, FrameEx, LocatorEx } from './matchers'; @@ -46,14 +46,14 @@ export async function toMatchAriaSnapshot( expectTypes(receiver, ['Page', 'Locator'], matcherName); const locator = (receiver as any)._apiName === 'Page' ? undefined : receiver as LocatorEx; - const testInfo = globals.currentTestInfo(); + const testInfo = expectConfig().testInfo; if (!testInfo) throw new Error(`${matcherName}() must be called during the test`); - if (testInfo._projectInternal.project.ignoreSnapshots) + if (expectConfig().ignoreSnapshots) return { pass: !this.isNot, message: () => '', name: 'toMatchAriaSnapshot', expected: '' }; - const updateSnapshots = testInfo.config.updateSnapshots; + const updateSnapshots = expectConfig().updateSnapshots; let expected: string; let timeout: number; @@ -66,7 +66,7 @@ export async function toMatchAriaSnapshot( expectedPath = testInfo._resolveSnapshotPaths('aria', expectedParam?.name, 'updateSnapshotIndex').absoluteSnapshotPath; // in 1.51, we changed the default template to use .aria.yml extension // for backwards compatibility, we check for the legacy .yml extension - if (!(await fileExistsAsync(expectedPath)) && await fileExistsAsync(legacyPath)) + if (!(await existsAsync(expectedPath)) && await existsAsync(legacyPath)) expectedPath = legacyPath; expected = await fs.promises.readFile(expectedPath, 'utf8').catch(() => ''); timeout = expectedParam?.timeout ?? this.timeout; @@ -85,7 +85,7 @@ export async function toMatchAriaSnapshot( expected = unshift(expected); - const globalChildren = testInfo._projectInternal.expect?.toMatchAriaSnapshot?.children; + const globalChildren = expectConfig().toMatchAriaSnapshot?.children; if (globalChildren && !expected.match(/^- \/children:/m)) expected = `- /children: ${globalChildren}\n` + expected; @@ -137,8 +137,7 @@ export async function toMatchAriaSnapshot( const relativePath = path.relative(process.cwd(), expectedPath); if (updateSnapshots === 'missing') { const message = `A snapshot doesn't exist at ${relativePath}, writing actual.`; - testInfo._hasNonRetriableError = true; - testInfo._failWithError(new Error(message)); + testInfo._failWithError(new Error(message), 'shouldNotRetry'); } else { const message = `A snapshot is generated at ${relativePath}.`; /* eslint-disable no-console */ @@ -149,8 +148,7 @@ export async function toMatchAriaSnapshot( const suggestedRebaseline = `\`\n${escapeTemplateString(indent(typedReceived.regex, '{indent} '))}\n{indent}\``; if (updateSnapshots === 'missing') { const message = 'A snapshot is not provided, generating new baseline.'; - testInfo._hasNonRetriableError = true; - testInfo._failWithError(new Error(message)); + testInfo._failWithError(new Error(message), 'shouldNotRetry'); } // TODO: ideally, we should return "pass: true" here because this matcher passes // when regenerating baselines. However, we can only access suggestedRebaseline in case diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index f3e11867df6ac..490f15e1cb172 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -21,14 +21,14 @@ import colors from 'colors/safe'; import { getMimeTypeForPath } from '@isomorphic/mimeType'; import { isString } from '@isomorphic/stringUtils'; import { compareBuffersOrStrings, getComparator } from '@utils/comparators'; -import { callLogText, formatMatcherMessage } from '@utils/expectUtils'; -import { addSuffixToFilePath, expectTypes } from '../util'; -import * as globals from '../globals'; +import { addSuffixToFilePath } from '@utils/fileUtils'; + +import { callLogText, expectTypes, formatMatcherMessage } from './matcherHint'; +import { expectConfig } from './expect'; -import type { config } from '../common'; import type { MatcherResult } from './matcherHint'; import type { ExpectMatcherStateInternal } from './matchers'; -import type { TestInfoImpl, TestStepInfoImpl } from '../worker/testInfo'; +import type { ExpectTestInfo, ExpectStepInfo } from './expect'; import type { Locator, Page } from 'playwright-core'; import type { ExpectScreenshotOptions, Page as PageEx } from 'playwright-core/lib/client/page'; import type { Comparator, ImageComparatorOptions } from '@utils/comparators'; @@ -37,7 +37,11 @@ type NameOrSegments = string | string[]; type ImageMatcherResult = MatcherResult & { diff?: string }; -type ToHaveScreenshotConfigOptions = NonNullable['toHaveScreenshot']> & { +type ToHaveScreenshotConfigOptions = ImageComparatorOptions & { + animations?: 'allow' | 'disabled'; + caret?: 'hide' | 'initial'; + scale?: 'css' | 'device'; + stylePath?: string | string[]; _comparator?: string; }; @@ -67,7 +71,7 @@ const NonConfigProperties: (keyof ToHaveScreenshotOptions)[] = [ // Keep in sync with above (end). class SnapshotHelper { - readonly testInfo: TestInfoImpl; + readonly testInfo: ExpectTestInfo; readonly name: string; readonly attachmentBaseName: string; readonly legacyExpectedPath: string; @@ -86,7 +90,7 @@ class SnapshotHelper { constructor( state: ExpectMatcherStateInternal, - testInfo: TestInfoImpl, + testInfo: ExpectTestInfo, matcherName: 'toMatchSnapshot' | 'toHaveScreenshot', locator: Locator | undefined, anonymousSnapshotExtension: string | undefined, @@ -138,7 +142,7 @@ class SnapshotHelper { this.matcherName = matcherName; this.locator = locator; - this.updateSnapshots = testInfo.config.updateSnapshots; + this.updateSnapshots = expectConfig().updateSnapshots; this.mimeType = getMimeTypeForPath(path.basename(this.expectedPath)) ?? 'application/octet-stream'; this.comparator = getComparator(this.mimeType); @@ -182,7 +186,7 @@ class SnapshotHelper { return this.createMatcherResult(message, true); } - handleMissing(actual: Buffer | string, step: TestStepInfoImpl | undefined): ImageMatcherResult { + handleMissing(actual: Buffer | string, step: ExpectStepInfo | undefined): ImageMatcherResult { const isWriteMissingMode = this.updateSnapshots !== 'none'; if (isWriteMissingMode) writeFileSync(this.expectedPath, actual); @@ -196,8 +200,7 @@ class SnapshotHelper { return this.createMatcherResult(message, true); } if (this.updateSnapshots === 'missing') { - this.testInfo._hasNonRetriableError = true; - this.testInfo._failWithError(new Error(message)); + this.testInfo._failWithError(new Error(message), 'shouldNotRetry'); return this.createMatcherResult('', true); } return this.createMatcherResult(message, false); @@ -211,7 +214,7 @@ class SnapshotHelper { header: string, diffError: string, log: string[] | undefined, - step: TestStepInfoImpl | undefined): ImageMatcherResult { + step: ExpectStepInfo | undefined): ImageMatcherResult { const output = [`${header}${indent(diffError, ' ')}`]; if (this.name) { output.push(''); @@ -255,16 +258,16 @@ export function toMatchSnapshot( nameOrOptions: NameOrSegments | { name?: NameOrSegments } & ImageComparatorOptions = {}, optOptions: ImageComparatorOptions = {} ): MatcherResult { - const testInfo = globals.currentTestInfo(); + const testInfo = expectConfig().testInfo; if (!testInfo) throw new Error(`toMatchSnapshot() must be called during the test`); if (received instanceof Promise) throw new Error('An unresolved Promise was passed to toMatchSnapshot(), make sure to resolve it by adding await to it.'); - if (testInfo._projectInternal.project.ignoreSnapshots) + if (expectConfig().ignoreSnapshots) return { pass: !this.isNot, message: () => '', name: 'toMatchSnapshot', expected: nameOrOptions }; - const configOptions = testInfo._projectInternal.expect?.toMatchSnapshot || {}; + const configOptions = expectConfig().toMatchSnapshot || {}; const helper = new SnapshotHelper( this, testInfo, 'toMatchSnapshot', undefined, '.' + determineFileExtension(received), configOptions, nameOrOptions, optOptions); @@ -326,16 +329,16 @@ export async function toHaveScreenshot( nameOrOptions: NameOrSegments | { name?: NameOrSegments } & ToHaveScreenshotOptions = {}, optOptions: ToHaveScreenshotOptions = {} ): Promise> { - const testInfo = globals.currentTestInfo(); + const testInfo = expectConfig().testInfo; if (!testInfo) throw new Error(`toHaveScreenshot() must be called during the test`); - if (testInfo._projectInternal.project.ignoreSnapshots) + if (expectConfig().ignoreSnapshots) return { pass: !this.isNot, message: () => '', name: 'toHaveScreenshot', expected: nameOrOptions }; expectTypes(pageOrLocator, ['Page', 'Locator'], 'toHaveScreenshot'); const [page, locator] = (pageOrLocator as any)._apiName === 'Page' ? [(pageOrLocator as PageEx), undefined] : [(pageOrLocator as Locator).page() as PageEx, pageOrLocator as Locator]; - const configOptions = testInfo._projectInternal.expect?.toHaveScreenshot || {}; + const configOptions = expectConfig().toHaveScreenshot || {}; const helper = new SnapshotHelper(this, testInfo, 'toHaveScreenshot', locator, undefined, configOptions, nameOrOptions, optOptions); if (!helper.expectedPath.toLowerCase().endsWith('.png')) throw new Error(`Screenshot name "${path.basename(helper.expectedPath)}" must have '.png' extension`); diff --git a/packages/playwright/src/matchers/toMatchText.ts b/packages/playwright/src/matchers/toMatchText.ts index bff1132654593..65f46134fc3e8 100644 --- a/packages/playwright/src/matchers/toMatchText.ts +++ b/packages/playwright/src/matchers/toMatchText.ts @@ -14,9 +14,7 @@ * limitations under the License. */ -import { formatMatcherMessage, printReceivedStringContainExpectedResult, printReceivedStringContainExpectedSubstring } from '@utils/expectUtils'; - -import { expectTypes } from '../util'; +import { expectTypes, formatMatcherMessage, printReceivedStringContainExpectedResult, printReceivedStringContainExpectedSubstring } from './matcherHint'; import type { MatcherResult } from './matcherHint'; import type { Page, Locator } from 'playwright-core'; diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 5d2406030c5e2..9d6bb0e087dd5 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -25,7 +25,7 @@ import { createGuid } from '@utils/crypto'; import { sanitizeForFilePath } from '@utils/fileUtils'; import { currentZone } from '@utils/zones'; -import { TimeoutManager, TimeoutManagerError, kMaxDeadline } from './timeoutManager'; +import { TimeoutManager, TimeoutManagerError } from './timeoutManager'; import { addSuffixToFilePath, filteredStackTrace, getContainedPath, normalizeAndSaveAttachment, sanitizeFilePathBeforeExtension, trimLongString, windowsFilesystemFriendlyLength } from '../util'; import { TestTracing } from './testTracing'; import { testInfoError } from './util'; @@ -160,13 +160,8 @@ export class TestInfoImpl implements TestInfo { // Ignored. } - _deadlineForMatcher(timeout: number): { deadline: number, timeoutMessage: string } { - const startTime = monotonicTime(); - const matcherDeadline = timeout ? startTime + timeout : kMaxDeadline; - const testDeadline = this._timeoutManager.currentSlotDeadline() - 250; - const matcherMessage = `Timeout ${timeout}ms exceeded while waiting on the predicate`; - const testMessage = `Test timeout of ${this.timeout}ms exceeded`; - return { deadline: Math.min(testDeadline, matcherDeadline), timeoutMessage: testDeadline < matcherDeadline ? testMessage : matcherMessage }; + _deadline(): { deadline: number, timeout: number } { + return { deadline: this._timeoutManager.currentSlotDeadline(), timeout: this.timeout }; } constructor( @@ -408,7 +403,9 @@ export class TestInfoImpl implements TestInfo { this.status = 'interrupted'; } - _failWithError(error: Error | unknown) { + _failWithError(error: Error | unknown, shouldNotRetry?: 'shouldNotRetry') { + if (shouldNotRetry) + this._hasNonRetriableError = true; if (this.status === 'passed' || this.status === 'skipped') this.status = error instanceof TimeoutManagerError ? 'timedOut' : 'failed'; const serialized = testInfoError(error); diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index bb1b1a7019043..4091ef4dc7454 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -21,7 +21,8 @@ import { gracefullyCloseAll } from '@utils/processLauncher'; import { configLoader, fixtures, ipc, poolBuilder, ProcessRunner, suiteUtils, testLoader } from '../common'; import * as globals from '../globals'; -import { debugTest, relativeFilePath } from '../util'; +import { setExpectConfig } from '../matchers/expect'; +import { debugTest, filteredStackTrace, relativeFilePath } from '../util'; import { FixtureRunner } from './fixtureRunner'; import { TestSkipError, TestInfoImpl, emtpyTestInfoCallbacks } from './testInfo'; import { testInfoError } from './util'; @@ -333,6 +334,17 @@ export class WorkerMain extends ProcessRunner { this._currentTest = testInfo; globals.setCurrentTestInfo(testInfo); + setExpectConfig({ + testInfo, + filteredStackTrace, + ignoreSnapshots: testInfo._projectInternal.project.ignoreSnapshots, + updateSnapshots: testInfo.config.updateSnapshots, + timeout: testInfo._projectInternal.expect?.timeout, + toHaveScreenshot: testInfo._projectInternal.expect?.toHaveScreenshot, + toMatchSnapshot: testInfo._projectInternal.expect?.toMatchSnapshot, + toMatchAriaSnapshot: testInfo._projectInternal.expect?.toMatchAriaSnapshot, + toPass: testInfo._projectInternal.expect?.toPass, + }); this.dispatchEvent('testBegin', buildTestBeginPayload(testInfo)); const isSkipped = testInfo.expectedStatus === 'skipped'; @@ -511,6 +523,7 @@ export class WorkerMain extends ProcessRunner { this._currentTest = null; globals.setCurrentTestInfo(null); + setExpectConfig({ testInfo: null, filteredStackTrace, ignoreSnapshots: false, updateSnapshots: 'missing' }); this.dispatchEvent('testEnd', buildTestEndPayload(testInfo)); const preserveOutput = this._config.config.preserveOutput === 'always' || diff --git a/utils/build/build.js b/utils/build/build.js index ba2f71a2d109a..cdeb5bcd4a028 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -858,6 +858,7 @@ steps.push(new EsbuildStep({ '../globals', '../package', '../utils', + '../matchers/expect', ], plugins: [dynamicImportToRequirePlugin], }, [filePath('packages/playwright/src')]));