Improved diffing to match toMatchText

This commit is contained in:
Adam Gastineau 2025-01-21 11:30:36 -08:00
parent 7ddbad2987
commit e83e203aa0
4 changed files with 118 additions and 71 deletions

View file

@ -15,10 +15,12 @@
*/ */
import type { ExpectMatcherState } from '../../types/test'; import type { ExpectMatcherState } from '../../types/test';
import { matcherHint } from './matcherHint'; import { kNoElementsFoundError, matcherHint } from './matcherHint';
import { colors } from 'playwright-core/lib/utilsBundle'; import { colors } from 'playwright-core/lib/utilsBundle';
import type { Locator } from 'playwright-core'; import type { Locator } from 'playwright-core';
import { EXPECTED_COLOR } from '../common/expectBundle'; import { EXPECTED_COLOR, printReceived } from '../common/expectBundle';
import { printReceivedStringContainExpectedResult, printReceivedStringContainExpectedSubstring } from './expect';
import { callLogText } from '../util';
export function toMatchExpectedStringOrPredicateVerification( export function toMatchExpectedStringOrPredicateVerification(
state: ExpectMatcherState, state: ExpectMatcherState,
@ -49,3 +51,54 @@ export function toMatchExpectedStringOrPredicateVerification(
].join('\n\n')); ].join('\n\n'));
} }
} }
export function textMatcherMessage(state: ExpectMatcherState, matcherName: string, receiver: Locator | undefined, expression: string, expected: string | RegExp | Function, received: string | undefined, callLog: string[] | undefined, stringName: string, pass: boolean, didTimeout: boolean, timeout: number): string {
const matcherOptions = {
isNot: state.isNot,
promise: state.promise,
};
const receivedString = received || '';
const messagePrefix = matcherHint(state, receiver, matcherName, expression, undefined, matcherOptions, didTimeout ? timeout : undefined);
const notFound = received === kNoElementsFoundError;
let printedReceived: string | undefined;
let printedExpected: string | undefined;
let printedDiff: string | undefined;
if (typeof expected === 'function') {
printedExpected = `Expected predicate to ${!state.isNot ? 'succeed' : 'fail'}`;
printedReceived = `Received string: ${printReceived(receivedString)}`;
} else {
if (pass) {
if (typeof expected === 'string') {
if (notFound) {
printedExpected = `Expected ${stringName}: not ${state.utils.printExpected(expected)}`;
printedReceived = `Received: ${received}`;
} else {
printedExpected = `Expected ${stringName}: not ${state.utils.printExpected(expected)}`;
const formattedReceived = printReceivedStringContainExpectedSubstring(receivedString, receivedString.indexOf(expected), expected.length);
printedReceived = `Received string: ${formattedReceived}`;
}
} else {
if (notFound) {
printedExpected = `Expected pattern: not ${state.utils.printExpected(expected)}`;
printedReceived = `Received: ${received}`;
} else {
printedExpected = `Expected pattern: not ${state.utils.printExpected(expected)}`;
const formattedReceived = printReceivedStringContainExpectedResult(receivedString, typeof expected.exec === 'function' ? expected.exec(receivedString) : null);
printedReceived = `Received string: ${formattedReceived}`;
}
}
} else {
const labelExpected = `Expected ${typeof expected === 'string' ? stringName : 'pattern'}`;
if (notFound) {
printedExpected = `${labelExpected}: ${state.utils.printExpected(expected)}`;
printedReceived = `Received: ${received}`;
} else {
printedDiff = state.utils.printDiffOrStringify(expected, receivedString, labelExpected, 'Received string', false);
}
}
}
const resultDetails = printedDiff ? printedDiff : printedExpected + '\n' + printedReceived;
return messagePrefix + resultDetails + callLogText(callLog);
}

View file

@ -26,8 +26,7 @@ import { currentTestInfo } from '../common/globals';
import { TestInfoImpl } from '../worker/testInfo'; import { TestInfoImpl } from '../worker/testInfo';
import type { ExpectMatcherState } from '../../types/test'; import type { ExpectMatcherState } from '../../types/test';
import { takeFirst } from '../common/config'; import { takeFirst } from '../common/config';
import { matcherHint } from './matcherHint'; import { textMatcherMessage, toMatchExpectedStringOrPredicateVerification } from './error';
import { toMatchExpectedStringOrPredicateVerification } from './error';
export interface LocatorEx extends Locator { export interface LocatorEx extends Locator {
_expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>; _expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>;
@ -407,17 +406,20 @@ export async function toHaveURL(
const timeout = options?.timeout ?? this.timeout; const timeout = options?.timeout ?? this.timeout;
let conditionSucceeded = false; let conditionSucceeded = false;
let lastCheckedURLString: string | undefined = undefined;
try { try {
await page.mainFrame().waitForURL( await page.mainFrame().waitForURL(
url => { url => {
const baseURL: string | undefined = (page.context() as any)._options const baseURL: string | undefined = (page.context() as any)._options
.baseURL; .baseURL;
lastCheckedURLString = url.toString();
if (options?.ignoreCase) { if (options?.ignoreCase) {
return ( return (
!this.isNot === !this.isNot ===
urlMatches( urlMatches(
baseURL?.toLocaleLowerCase(), baseURL?.toLocaleLowerCase(),
url.toString().toLocaleLowerCase(), lastCheckedURLString.toLocaleLowerCase(),
typeof expected === 'string' typeof expected === 'string'
? expected.toLocaleLowerCase() ? expected.toLocaleLowerCase()
: expected, : expected,
@ -429,7 +431,7 @@ export async function toHaveURL(
!this.isNot === !this.isNot ===
urlMatches( urlMatches(
baseURL, baseURL,
url.toString(), lastCheckedURLString,
expected, expected,
) )
); );
@ -445,22 +447,24 @@ export async function toHaveURL(
if (conditionSucceeded) if (conditionSucceeded)
return { pass: !this.isNot, message: () => '' }; return { pass: !this.isNot, message: () => '' };
const matcherOptions = {
isNot: this.isNot,
promise: this.promise,
};
return { return {
pass: this.isNot, pass: this.isNot,
message: () => message: () =>
matcherHint( textMatcherMessage(
this, this,
undefined,
matcherName, matcherName,
expression,
undefined, undefined,
matcherOptions, expression,
expected,
lastCheckedURLString,
undefined,
'string',
this.isNot,
true,
timeout, timeout,
), ),
actual: lastCheckedURLString,
timeout,
}; };
} }

View file

@ -15,16 +15,11 @@
*/ */
import { expectTypes, callLogText } from '../util'; import { expectTypes } from '../util';
import {
printReceivedStringContainExpectedResult,
printReceivedStringContainExpectedSubstring
} from './expect';
import type { ExpectMatcherState } from '../../types/test'; import type { ExpectMatcherState } from '../../types/test';
import { kNoElementsFoundError, matcherHint } from './matcherHint';
import type { MatcherResult } from './matcherHint'; import type { MatcherResult } from './matcherHint';
import type { Locator } from 'playwright-core'; import type { Locator } from 'playwright-core';
import { toMatchExpectedStringOrPredicateVerification } from './error'; import { textMatcherMessage, toMatchExpectedStringOrPredicateVerification } from './error';
export async function toMatchText( export async function toMatchText(
this: ExpectMatcherState, this: ExpectMatcherState,
@ -50,57 +45,25 @@ export async function toMatchText(
}; };
} }
const matcherOptions = {
isNot: this.isNot,
promise: this.promise,
};
const stringSubstring = options.matchSubstring ? 'substring' : 'string'; const stringSubstring = options.matchSubstring ? 'substring' : 'string';
const receivedString = received || '';
const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined);
const notFound = received === kNoElementsFoundError;
let printedReceived: string | undefined;
let printedExpected: string | undefined;
let printedDiff: string | undefined;
if (pass) {
if (typeof expected === 'string') {
if (notFound) {
printedExpected = `Expected ${stringSubstring}: not ${this.utils.printExpected(expected)}`;
printedReceived = `Received: ${received}`;
} else {
printedExpected = `Expected ${stringSubstring}: not ${this.utils.printExpected(expected)}`;
const formattedReceived = printReceivedStringContainExpectedSubstring(receivedString, receivedString.indexOf(expected), expected.length);
printedReceived = `Received string: ${formattedReceived}`;
}
} else {
if (notFound) {
printedExpected = `Expected pattern: not ${this.utils.printExpected(expected)}`;
printedReceived = `Received: ${received}`;
} else {
printedExpected = `Expected pattern: not ${this.utils.printExpected(expected)}`;
const formattedReceived = printReceivedStringContainExpectedResult(receivedString, typeof expected.exec === 'function' ? expected.exec(receivedString) : null);
printedReceived = `Received string: ${formattedReceived}`;
}
}
} else {
const labelExpected = `Expected ${typeof expected === 'string' ? stringSubstring : 'pattern'}`;
if (notFound) {
printedExpected = `${labelExpected}: ${this.utils.printExpected(expected)}`;
printedReceived = `Received: ${received}`;
} else {
printedDiff = this.utils.printDiffOrStringify(expected, receivedString, labelExpected, 'Received string', false);
}
}
const message = () => {
const resultDetails = printedDiff ? printedDiff : printedExpected + '\n' + printedReceived;
return messagePrefix + resultDetails + callLogText(log);
};
return { return {
name: matcherName, name: matcherName,
expected, expected,
message, message: () =>
textMatcherMessage(
this,
matcherName,
receiver,
'locator',
expected,
received,
log,
stringSubstring,
pass,
!!timedOut,
timeout,
),
pass, pass,
actual: received, actual: received,
log, log,

View file

@ -241,20 +241,47 @@ test.describe('toHaveURL', () => {
await expect(page).toHaveURL('data:text/html,<div>A</div>'); await expect(page).toHaveURL('data:text/html,<div>A</div>');
}); });
test('fail', async ({ page }) => { test('fail string', async ({ page }) => {
await page.goto('data:text/html,<div>B</div>'); await page.goto('data:text/html,<div>A</div>');
const error = await expect(page).toHaveURL('wrong', { timeout: 1000 }).catch(e => e); const error = await expect(page).toHaveURL('wrong', { timeout: 1000 }).catch(e => e);
expect(stripVTControlCharacters(error.message)).toContain('Timed out 1000ms waiting for expect(page).toHaveURL(expected)'); expect(stripVTControlCharacters(error.message)).toContain('Timed out 1000ms waiting for expect(page).toHaveURL(expected)');
expect(stripVTControlCharacters(error.message)).toContain('Expected string: "wrong"\nReceived string: "data:text/html,<div>A</div>"');
}); });
test('fail with invalid argument', async ({ page }) => { test('fail with invalid argument', async ({ page }) => {
await page.goto('data:text/html,<div>B</div>'); await page.goto('data:text/html,<div>A</div>');
// @ts-expect-error // @ts-expect-error
const error = await expect(page).toHaveURL({}).catch(e => e); const error = await expect(page).toHaveURL({}).catch(e => e);
expect(stripVTControlCharacters(error.message)).toContain('expect(page).toHaveURL(expected)\n\n\nMatcher error: expected value should be a string, regular expression, or predicate'); expect(stripVTControlCharacters(error.message)).toContain('expect(page).toHaveURL(expected)\n\n\n\nMatcher error: expected value must be a string, regular expression, or predicate');
expect(stripVTControlCharacters(error.message)).toContain('Expected has type: object\nExpected has value: {}'); expect(stripVTControlCharacters(error.message)).toContain('Expected has type: object\nExpected has value: {}');
}); });
test('fail with positive predicate', async ({ page }) => {
await page.goto('data:text/html,<div>A</div>');
const error = await expect(page).toHaveURL(_url => false).catch(e => e);
expect(stripVTControlCharacters(error.message)).toContain('expect(page).toHaveURL(expected)');
expect(stripVTControlCharacters(error.message)).toContain('Expected predicate to succeed\nReceived string: "data:text/html,<div>A</div>"');
});
test('fail with negative predicate', async ({ page }) => {
await page.goto('data:text/html,<div>A</div>');
const error = await expect(page).not.toHaveURL(_url => true).catch(e => e);
expect(stripVTControlCharacters(error.message)).toContain('expect(page).not.toHaveURL(expected)');
expect(stripVTControlCharacters(error.message)).toContain('Expected predicate to fail\nReceived string: "data:text/html,<div>A</div>"');
});
test('resolve predicate on initial call', async ({ page }) => {
await page.goto('data:text/html,<div>A</div>');
await expect(page).toHaveURL(url => url.href === 'data:text/html,<div>A</div>', { timeout: 1000 });
});
test('resolve predicate after retries', async ({ page }) => {
await page.goto('data:text/html,<div>A</div>');
const expectPromise = expect(page).toHaveURL(url => url.href === 'data:text/html,<div>B</div>', { timeout: 1000 });
setTimeout(() => page.goto('data:text/html,<div>B</div>'), 500);
await expectPromise;
});
test('support ignoreCase', async ({ page }) => { test('support ignoreCase', async ({ page }) => {
await page.goto('data:text/html,<div>A</div>'); await page.goto('data:text/html,<div>A</div>');
await expect(page).toHaveURL('DATA:teXT/HTml,<div>a</div>', { ignoreCase: true }); await expect(page).toHaveURL('DATA:teXT/HTml,<div>a</div>', { ignoreCase: true });