Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/playwright/src/common/DEPS.list
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@
[testType.ts]
../matchers/expect.ts

[globals.ts]
../matchers/expect.ts
6 changes: 6 additions & 0 deletions packages/playwright/src/common/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,18 @@
* limitations under the License.
*/

import { setExpectConfig } from '../matchers/expect';

import type { Suite } from './test';
import type { TestInfoImpl } from '../worker/testInfo';

let currentTestInfoValue: TestInfoImpl | null = null;
export function setCurrentTestInfo(testInfo: TestInfoImpl | null) {
currentTestInfoValue = testInfo;
setExpectConfig({
testInfo: testInfo ?? undefined,
...testInfo?._projectInternal.expect,
});
}
export function currentTestInfo(): TestInfoImpl | null {
return currentTestInfoValue;
Expand Down
4 changes: 1 addition & 3 deletions packages/playwright/src/matchers/DEPS.list
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
[*]
../common/
../mcp/test/browserBackend.ts
../common/expectBundle.ts
../util.ts
../utilsBundle.ts
../worker/testInfo.ts
58 changes: 46 additions & 12 deletions packages/playwright/src/matchers/expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { ExpectError, isJestError } from './matcherHint';
import {
computeMatcherTitleSuffix,
defaultDeadlineForMatcher,
toBeAttached,
toBeChecked,
toBeDisabled,
Expand Down Expand Up @@ -60,13 +61,47 @@ import { toHaveScreenshot, toMatchSnapshot } from './toMatchSnapshot';
import {
expect as expectLibrary,
} from '../common/expectBundle';
import { currentTestInfo } from '../common/globals';
import { filteredStackTrace } from '../util';
import { TestInfoImpl } from '../worker/testInfo';

import type { ExpectMatcherStateInternal } from './matchers';
import type { Expect } from '../../types/test';
import type { TestStepInfoImpl } from '../worker/testInfo';
import type { TestInfoImpl, TestStepInfoImpl } from '../worker/testInfo';

export type ExpectConfig = {
testInfo?: TestInfoImpl;
timeout?: number;
toHaveScreenshot?: {
threshold?: number;
maxDiffPixels?: number;
maxDiffPixelRatio?: number;
animations?: 'allow'|'disabled';
caret?: 'hide'|'initial';
scale?: 'css'|'device';
stylePath?: string|Array<string>;
pathTemplate?: string;
};
toMatchAriaSnapshot?: {
pathTemplate?: string;
children?: 'contain'|'equal'|'deep-equal';
};
toMatchSnapshot?: {
threshold?: number;
maxDiffPixels?: number;
maxDiffPixelRatio?: number;
};
toPass?: {
timeout?: number;
intervals?: Array<number>;
};
};

let currentConfig: ExpectConfig = {};
export function setExpectConfig(config: ExpectConfig) {
currentConfig = config;
}
export function expectConfig(): ExpectConfig {
return currentConfig;
}

type ExpectMessage = string | { message?: string };

Expand Down Expand Up @@ -158,7 +193,6 @@ 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;
};

Expand All @@ -184,7 +218,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,
Expand Down Expand Up @@ -297,8 +331,8 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
matcher = (...args: any[]) => pollMatcher(resolvedMatcherName, this._info, this._prefix, ...args);
}
return (...args: any[]) => {
const testInfo = currentTestInfo();
setMatcherCallContext({ expectInfo: this._info, testInfo });
const testInfo = expectConfig().testInfo;
setMatcherCallContext({ expectInfo: this._info });
if (!testInfo)
return matcher.call(target, ...args);

Expand Down Expand Up @@ -350,7 +384,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
};

try {
setMatcherCallContext({ expectInfo: this._info, testInfo, step: step.info });
setMatcherCallContext({ expectInfo: this._info, step: step.info });
const callback = () => matcher.call(target, ...args);
const result = currentZone().with('stepZone', step).run(callback);
if (result instanceof Promise)
Expand All @@ -365,13 +399,13 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
}

async function pollMatcher(qualifiedMatcherName: string, info: ExpectMetaInfo, prefix: string[], ...args: any[]) {
const testInfo = currentTestInfo();
const config = expectConfig();
const poll = info.poll!;
const timeout = poll.timeout ?? info.timeout ?? testInfo?._projectInternal?.expect?.timeout ?? defaultExpectTimeout;
const { deadline, timeoutMessage } = testInfo ? testInfo._deadlineForMatcher(timeout) : TestInfoImpl._defaultDeadlineForMatcher(timeout);
const timeout = poll.timeout ?? info.timeout ?? config.timeout ?? defaultExpectTimeout;
const { deadline, timeoutMessage } = config.testInfo ? config.testInfo._deadlineForMatcher(timeout) : defaultDeadlineForMatcher(timeout);

const result = await pollAgainstDeadline<Error|undefined>(async () => {
if (testInfo && currentTestInfo() !== testInfo)
if (config && expectConfig() !== config)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (config && expectConfig() !== config)
if (expectConfig() !== config)

return { continuePolling: false, result: undefined };

const innerInfo: ExpectMetaInfo = {
Expand Down
9 changes: 9 additions & 0 deletions packages/playwright/src/matchers/matcherHint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,12 @@ 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: string[], matcherName: string) {
if (typeof receiver !== 'object' || !types.includes(receiver.constructor.name)) {
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' : ''}`);
}
}
23 changes: 12 additions & 11 deletions packages/playwright/src/matchers/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,16 @@
* limitations under the License.
*/

import { asLocatorDescription, constructURLBasedOnBaseURL, isRegExp, isString, isTextualMimeType, isURLPattern, pollAgainstDeadline, serializeExpectedTextValues, formatMatcherMessage } from 'playwright-core/lib/utils';
import { asLocatorDescription, constructURLBasedOnBaseURL, isRegExp, isString, isTextualMimeType, isURLPattern, pollAgainstDeadline, serializeExpectedTextValues, formatMatcherMessage, monotonicTime } from 'playwright-core/lib/utils';
import { colors } from 'playwright-core/lib/utils';

import { expectTypes } from '../util';
import { toBeTruthy } from './toBeTruthy';
import { toEqual } from './toEqual';
import { toHaveURLWithPredicate } from './toHaveURL';
import { toMatchText } from './toMatchText';
import { toHaveScreenshotStepTitle } from './toMatchSnapshot';
import { takeFirst } from '../common/config';
import { currentTestInfo } from '../common/globals';
import { TestInfoImpl } from '../worker/testInfo';
import { MatcherResult } from './matcherHint';
import { expectConfig } from './expect';
import { expectTypes, MatcherResult } from './matcherHint';

import type { ExpectMatcherState } from '../../types/test';
import type { TestStepInfoImpl } from '../worker/testInfo';
Expand Down Expand Up @@ -476,13 +473,13 @@ export async function toPass(
timeout?: number,
} = {},
) {
const testInfo = 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 config = expectConfig();
const timeout = options.timeout ?? config.toPass?.timeout ?? 0;
const intervals = options.intervals ?? config.toPass?.intervals ?? [100, 250, 500, 1000];

const { deadline, timeoutMessage } = testInfo ? testInfo._deadlineForMatcher(timeout) : TestInfoImpl._defaultDeadlineForMatcher(timeout);
const { deadline, timeoutMessage } = config.testInfo ? config.testInfo._deadlineForMatcher(timeout) : defaultDeadlineForMatcher(timeout);
const result = await pollAgainstDeadline<Error|undefined>(async () => {
if (testInfo && currentTestInfo() !== testInfo)
if (config && expectConfig() !== config)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (config && expectConfig() !== config)
if (expectConfig() !== config)

return { continuePolling: false, result: undefined };
try {
await callback();
Expand Down Expand Up @@ -517,3 +514,7 @@ export function computeMatcherTitleSuffix(matcherName: string, receiver: any, ar
}
return {};
}

export function defaultDeadlineForMatcher(timeout: number): { deadline: number; timeoutMessage: string } {
return { deadline: (timeout ? monotonicTime() + timeout : 0), timeoutMessage: `Timeout ${timeout}ms exceeded while waiting on the predicate` };
}
2 changes: 1 addition & 1 deletion packages/playwright/src/matchers/toBeTruthy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import { formatMatcherMessage } from 'playwright-core/lib/utils';

import { expectTypes } from '../util';
import { expectTypes } from './matcherHint';

import type { MatcherResult } from './matcherHint';
import type { Locator } from 'playwright-core';
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright/src/matchers/toEqual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import { formatMatcherMessage, isRegExp } from 'playwright-core/lib/utils';

import { expectTypes } from '../util';
import { expectTypes } from './matcherHint';

import type { MatcherResult } from './matcherHint';
import type { Locator } from 'playwright-core';
Expand Down
15 changes: 11 additions & 4 deletions packages/playwright/src/matchers/toMatchAriaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,9 @@

import fs from 'fs';
import path from 'path';

import { formatMatcherMessage, escapeTemplateString, isString, printReceivedStringContainExpectedSubstring } from 'playwright-core/lib/utils';

import { fileExistsAsync } from '../util';
import { currentTestInfo } from '../common/globals';
import { expectConfig } from './expect';

import type { MatcherResult } from './matcherHint';
import type { ExpectMatcherStateInternal, LocatorEx } from './matchers';
Expand All @@ -42,7 +40,7 @@ export async function toMatchAriaSnapshot(
): Promise<MatcherResult<string | RegExp, string>> {
const matcherName = 'toMatchAriaSnapshot';

const testInfo = currentTestInfo();
const testInfo = expectConfig().testInfo;
if (!testInfo)
throw new Error(`toMatchAriaSnapshot() must be called during the test`);

Expand Down Expand Up @@ -180,3 +178,12 @@ function unshift(snapshot: string): string {
function indent(snapshot: string, indent: string): string {
return snapshot.split('\n').map(line => indent + line).join('\n');
}

async function fileExistsAsync(resolved: string) {
try {
const stat = await fs.promises.stat(resolved);
return stat.isFile();
} catch {
return false;
}
}
18 changes: 12 additions & 6 deletions packages/playwright/src/matchers/toMatchSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ import { callLogText, formatMatcherMessage, compareBuffersOrStrings, getComparat
import { colors } from 'playwright-core/lib/utils';
import { mime } from 'playwright-core/lib/utilsBundle';

import { addSuffixToFilePath, expectTypes } from '../util';
import { currentTestInfo } from '../common/globals';
import { expectTypes } from './matcherHint';
import { expectConfig } from './expect';

import type { MatcherResult } from './matcherHint';
import type { ExpectMatcherStateInternal } from './matchers';
import type { FullProjectInternal } from '../common/config';
import type { ExpectConfig } from './expect';
import type { TestInfoImpl, TestStepInfoImpl } from '../worker/testInfo';
import type { Locator, Page } from 'playwright-core';
import type { ExpectScreenshotOptions, Page as PageEx } from 'playwright-core/lib/client/page';
Expand All @@ -36,7 +36,7 @@ type NameOrSegments = string | string[];

type ImageMatcherResult = MatcherResult<string, string> & { diff?: string };

type ToHaveScreenshotConfigOptions = NonNullable<NonNullable<FullProjectInternal['expect']>['toHaveScreenshot']> & {
type ToHaveScreenshotConfigOptions = NonNullable<ExpectConfig['toHaveScreenshot']> & {
_comparator?: string;
};

Expand Down Expand Up @@ -254,7 +254,7 @@ export function toMatchSnapshot(
nameOrOptions: NameOrSegments | { name?: NameOrSegments } & ImageComparatorOptions = {},
optOptions: ImageComparatorOptions = {}
): MatcherResult<NameOrSegments | { name?: NameOrSegments }, string> {
const testInfo = currentTestInfo();
const testInfo = expectConfig().testInfo;
if (!testInfo)
throw new Error(`toMatchSnapshot() must be called during the test`);
if (received instanceof Promise)
Expand Down Expand Up @@ -325,7 +325,7 @@ export async function toHaveScreenshot(
nameOrOptions: NameOrSegments | { name?: NameOrSegments } & ToHaveScreenshotOptions = {},
optOptions: ToHaveScreenshotOptions = {}
): Promise<MatcherResult<NameOrSegments | { name?: NameOrSegments }, string>> {
const testInfo = currentTestInfo();
const testInfo = expectConfig().testInfo;
if (!testInfo)
throw new Error(`toHaveScreenshot() must be called during the test`);

Expand Down Expand Up @@ -461,3 +461,9 @@ async function loadScreenshotStyles(stylePath?: string | string[]): Promise<stri
}));
return styles.join('\n').trim() || undefined;
}

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;
}
2 changes: 1 addition & 1 deletion packages/playwright/src/matchers/toMatchText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import { formatMatcherMessage, printReceivedStringContainExpectedResult, printReceivedStringContainExpectedSubstring } from 'playwright-core/lib/utils';

import { expectTypes } from '../util';
import { expectTypes } from './matcherHint';

import type { MatcherResult } from './matcherHint';
import type { Page, Locator } from 'playwright-core';
Expand Down
11 changes: 7 additions & 4 deletions packages/playwright/src/mcp/test/testContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import { debug } from 'playwright-core/lib/utilsBundle';
import { terminalScreen } from '../../reporters/base';
import ListReporter from '../../reporters/list';
import { StringWriteStream } from './streams';
import { fileExistsAsync } from '../../util';
import { TestRunner, TestRunnerEvent } from '../../runner/testRunner';
import { ensureSeedFile, seedProject } from './seed';
import { resolveConfigLocation } from '../../common/configLoader';
Expand Down Expand Up @@ -160,9 +159,13 @@ export class TestContext {
candidateFiles.push(path.resolve(this.rootPath, seedFile));
let resolvedSeedFile: string | undefined;
for (const candidateFile of candidateFiles) {
if (await fileExistsAsync(candidateFile)) {
resolvedSeedFile = candidateFile;
break;
try {
const stat = await fs.promises.stat(candidateFile);
if (stat.isFile()) {
resolvedSeedFile = candidateFile;
break;
}
} catch {
}
}
if (!resolvedSeedFile)
Expand Down
24 changes: 0 additions & 24 deletions packages/playwright/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,15 +187,6 @@ export function errorWithFile(file: string, message: string) {
return new Error(`${relativeFilePath(file)}: ${message}`);
}

export function expectTypes(receiver: any, types: string[], matcherName: string) {
if (typeof receiver !== 'object' || !types.includes(receiver.constructor.name)) {
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' : ''}`);
}
}

export const windowsFilesystemFriendlyLength = 60;

export function trimLongString(s: string, length = 100) {
Expand All @@ -208,12 +199,6 @@ export function trimLongString(s: string, length = 100) {
return s.substring(0, start) + middle + s.slice(-end);
}

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 sanitizeFilePathBeforeExtension(filePath: string, ext?: string): string {
ext ??= path.extname(filePath);
const base = filePath.substring(0, filePath.length - ext.length);
Expand Down Expand Up @@ -390,15 +375,6 @@ function fileExists(resolved: string) {
return fs.statSync(resolved, { throwIfNoEntry: false })?.isFile();
}

export async function fileExistsAsync(resolved: string) {
try {
const stat = await fs.promises.stat(resolved);
return stat.isFile();
} catch {
return false;
}
}

function dirExists(resolved: string) {
return fs.statSync(resolved, { throwIfNoEntry: false })?.isDirectory();
}
Expand Down
Loading
Loading