This commit is contained in:
Yury Semikhatsky 2024-10-17 18:42:04 -07:00
parent 78c5ff0817
commit 0669a71309
14 changed files with 151 additions and 82 deletions

View file

@ -6,16 +6,22 @@ Information about an error thrown during test execution.
## property: TestInfoError.actual ## property: TestInfoError.actual
* since: v1.49 * since: v1.49
- type: ?<[any]> - type: ?<[string]>
Actual value. Actual value.
## property: TestInfoError.expected ## property: TestInfoError.expected
* since: v1.49 * since: v1.49
- type: ?<[any]> - type: ?<[string]>
Expected value. Expected value.
## property: TestInfoError.locator
* since: v1.49
- type: ?<[string]>
Receiver's locator.
## property: TestInfoError.log ## property: TestInfoError.log
* since: v1.49 * since: v1.49
- type: ?<[Array]<[string]>> - type: ?<[Array]<[string]>>

View file

@ -6,16 +6,22 @@ Information about an error thrown during test execution.
## property: TestError.actual ## property: TestError.actual
* since: v1.49 * since: v1.49
- type: ?<[any]> - type: ?<[string]>
Actual value. Actual value.
## property: TestError.expected ## property: TestError.expected
* since: v1.49 * since: v1.49
- type: ?<[any]> - type: ?<[string]>
Expected value. Expected value.
## property: TestError.locator
* since: v1.49
- type: ?<[string]>
Receiver's locator.
## property: TestError.log ## property: TestError.log
* since: v1.49 * since: v1.49
- type: ?<[Array]<[string]>> - type: ?<[Array]<[string]>>

View file

@ -19,22 +19,50 @@ import * as React from 'react';
import './testErrorView.css'; import './testErrorView.css';
import type { ImageDiff } from '@web/shared/imageDiffView'; import type { ImageDiff } from '@web/shared/imageDiffView';
import { ImageDiffView } from '@web/shared/imageDiffView'; import { ImageDiffView } from '@web/shared/imageDiffView';
import { ErrorDetails } from './types';
export const TestErrorView: React.FC<{ export const TestErrorView: React.FC<{
error: string; error: ErrorDetails;
testId?: string; testId?: string;
}> = ({ error, testId }) => { }> = ({ error, testId }) => {
const html = React.useMemo(() => ansiErrorToHtml(error), [error]); const html = React.useMemo(() => {
const formattedError = [];
if (error.shortMessage)
formattedError.push('Error: ' + error.shortMessage);
if (error.locator)
formattedError.push(`Locator: ${error.locator}`);
if (error.expected)
formattedError.push(`Expected: ${error.expected}`);
if (error.actual)
formattedError.push(`Received: ${error.actual}`);
// if (error.diff)
if (error.log) {
formattedError.push('Call log:');
formattedError.push(...(error.log?.map(line => ' - ' + line) || []));
}
if (error.snippet)
formattedError.push('', error.snippet);
return ansiErrorToHtml(formattedError.join('\n'));
}, [error]);
return <div className='test-error-view test-error-text' data-testId={testId} dangerouslySetInnerHTML={{ __html: html || '' }}></div>; return <div className='test-error-view test-error-text' data-testId={testId} dangerouslySetInnerHTML={{ __html: html || '' }}></div>;
}; };
export const TestScreenshotErrorView: React.FC<{ export const TestScreenshotErrorView: React.FC<{
errorPrefix?: string, error: ErrorDetails,
diff: ImageDiff, diff: ImageDiff,
errorSuffix?: string, }> = ({ error, diff }) => {
}> = ({ errorPrefix, diff, errorSuffix }) => { const prefixHtml = React.useMemo(() => ansiErrorToHtml(error.shortMessage), [error]);
const prefixHtml = React.useMemo(() => ansiErrorToHtml(errorPrefix), [errorPrefix]); const suffixHtml = React.useMemo(() => {
const suffixHtml = React.useMemo(() => ansiErrorToHtml(errorSuffix), [errorSuffix]); const errorSuffix = ['Call log:',
...(error.log?.map(line => ' - ' + line) || []),
'',
error.snippet,
'',
error.callStack,
].join('\n');
return ansiErrorToHtml(errorSuffix)
}, [error]);
return <div data-testid='test-screenshot-error-view' className='test-error-view'> return <div data-testid='test-screenshot-error-view' className='test-error-view'>
<div dangerouslySetInnerHTML={{ __html: prefixHtml || '' }} className='test-error-text' style={{ marginBottom: 20 }}></div> <div dangerouslySetInnerHTML={{ __html: prefixHtml || '' }} className='test-error-text' style={{ marginBottom: 20 }}></div>
<ImageDiffView key='image-diff' diff={diff} hideDetails={true}></ImageDiffView> <ImageDiffView key='image-diff' diff={diff} hideDetails={true}></ImageDiffView>
@ -73,3 +101,13 @@ const ansiColors = {
function escapeHTML(text: string): string { function escapeHTML(text: string): string {
return text.replace(/[&"<>]/g, c => ({ '&': '&amp;', '"': '&quot;', '<': '&lt;', '>': '&gt;' }[c]!)); return text.replace(/[&"<>]/g, c => ({ '&': '&amp;', '"': '&quot;', '<': '&lt;', '>': '&gt;' }[c]!));
} }
export function formatCallLog(log: string[] | undefined): string {
if (!log || !log.some(l => !!l))
return '';
return `
Call log:
${'- ' + (log || []).join('\n - ')}
`;
}

View file

@ -98,7 +98,7 @@ export const TestResultView: React.FC<{
{!!errors.length && <AutoChip header='Errors'> {!!errors.length && <AutoChip header='Errors'>
{errors.map((error, index) => { {errors.map((error, index) => {
if (error.type === 'screenshot') if (error.type === 'screenshot')
return <TestScreenshotErrorView key={'test-result-error-message-' + index} errorPrefix={error.errorPrefix} diff={error.diff!} errorSuffix={error.errorSuffix}></TestScreenshotErrorView>; return <TestScreenshotErrorView key={'test-result-error-message-' + index} error={error.error} diff={error.diff!}></TestScreenshotErrorView>;
return <TestErrorView key={'test-result-error-message-' + index} error={error.error!}></TestErrorView>; return <TestErrorView key={'test-result-error-message-' + index} error={error.error!}></TestErrorView>;
})} })}
</AutoChip>} </AutoChip>}
@ -155,25 +155,18 @@ function classifyErrors(testErrors: ErrorDetails[], diffs: ImageDiff[]) {
if (error.shortMessage?.includes('Screenshot comparison failed:') && error.actual && error.expected) { if (error.shortMessage?.includes('Screenshot comparison failed:') && error.actual && error.expected) {
const matchingDiff = diffs.find(diff => { const matchingDiff = diffs.find(diff => {
const attachmentName = diff.actual?.attachment.name; const attachmentName = diff.actual?.attachment.name;
return attachmentName && error.actual.endsWith(attachmentName); return attachmentName && error.actual?.endsWith(attachmentName);
}); });
const errorSuffix = ['Call log:', return {
...(error.log?.map(line => ' - ' + line) || []), type: 'screenshot',
'', diff: matchingDiff,
error.snippet, error,
'', };
error.callStack,
].join('\n');
if (matchingDiff) {
return {
type: 'screenshot',
diff: matchingDiff,
errorPrefix: error.shortMessage,
errorSuffix
};
}
} }
return { type: 'regular', error: error.message }; return {
type: 'regular',
error
};
}); });
} }

View file

@ -87,9 +87,10 @@ export type ErrorDetails = {
message: string; message: string;
location?: Location; location?: Location;
shortMessage?: string; shortMessage?: string;
locator?: string;
log?: string[]; log?: string[];
expected?: any; expected?: string;
actual?: any; actual?: string;
snippet?: string; snippet?: string;
callStack?: string; callStack?: string;
}; };

View file

@ -135,10 +135,18 @@ const matchers: MatchersObject = {
); );
}; };
const header = matcherHint(matcherName, undefined, undefined, options) + '\n';
const printedExpected = `${pass ? 'not ' : ''}${printExpected(expected)}`
const printedReceived = printReceived(received);
// Passing the actual and expected objects so that a custom reporter // Passing the actual and expected objects so that a custom reporter
// could access them, for example in order to display a custom visual diff, // could access them, for example in order to display a custom visual diff,
// or create a different error message // or create a different error message
return { actual: received, expected, message, name: matcherName, pass }; return { actual: received, expected, message, name: matcherName, pass,
header,
printedReceived,
printedExpected,
};
}, },
toBeCloseTo(received: number, expected: number, precision = 2) { toBeCloseTo(received: number, expected: number, precision = 2) {

View file

@ -40,6 +40,12 @@ export type MatcherResult<E, A> = {
actual?: A; actual?: A;
log?: string[]; log?: string[];
timeout?: number; timeout?: number;
locator?: string;
header?: string;
printedReceived?: string;
printedExpected?: string;
}; };
export class ExpectError extends Error { export class ExpectError extends Error {

View file

@ -58,30 +58,35 @@ export async function toMatchText(
const timeout = options.timeout ?? this.timeout; const timeout = options.timeout ?? this.timeout;
const { matches: pass, received, log, timedOut } = await query(!!this.isNot, timeout); const { matches: pass, received, log, timedOut } = await query(!!this.isNot, timeout);
if (pass === !this.isNot) {
return {
name: matcherName,
message: () => '',
pass,
expected
};
}
const stringSubstring = options.matchSubstring ? 'substring' : 'string'; const stringSubstring = options.matchSubstring ? 'substring' : 'string';
const receivedString = received || ''; const headerWithLocator = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined);
const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined); // Pass timeout and locator as fields on the error?
const header = matcherHint(this, undefined, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined);
const notFound = received === kNoElementsFoundError; const notFound = received === kNoElementsFoundError;
const message = () => { let printedReceived;
if (pass) { // {not} {string/substring/pattern} {formatted}
if (typeof expected === 'string') { let printedExpected = `${typeof expected === 'string' ? stringSubstring : 'pattern'} ${this.utils.printExpected(expected)}`;
if (notFound) if (pass) {
return messagePrefix + `Expected ${stringSubstring}: not ${this.utils.printExpected(expected)}\nReceived: ${received}` + callLogText(log); printedExpected = `not ${printedExpected}`;
const printedReceived = printReceivedStringContainExpectedSubstring(receivedString, receivedString.indexOf(expected), expected.length); const receivedString = received || '';
return messagePrefix + `Expected ${stringSubstring}: not ${this.utils.printExpected(expected)}\nReceived string: ${printedReceived}` + callLogText(log); printedReceived = notFound
} else { ? received
if (notFound) : typeof expected === 'string'
return messagePrefix + `Expected pattern: not ${this.utils.printExpected(expected)}\nReceived: ${received}` + callLogText(log); ? printReceivedStringContainExpectedSubstring(receivedString, receivedString.indexOf(expected), expected.length)
const printedReceived = printReceivedStringContainExpectedResult(receivedString, typeof expected.exec === 'function' ? expected.exec(receivedString) : null); : printReceivedStringContainExpectedResult(receivedString, typeof expected.exec === 'function' ? expected.exec(receivedString) : null);
return messagePrefix + `Expected pattern: not ${this.utils.printExpected(expected)}\nReceived string: ${printedReceived}` + callLogText(log); } else {
} printedReceived = notFound ? received : `string ${this.utils.printReceived(received)}`;
} else { }
const labelExpected = `Expected ${typeof expected === 'string' ? stringSubstring : 'pattern'}`; const message = () => `${headerWithLocator}Expected: ${printedExpected}\nReceived: ${printedReceived}` + callLogText(log);
if (notFound)
return messagePrefix + `${labelExpected}: ${this.utils.printExpected(expected)}\nReceived: ${received}` + callLogText(log);
return messagePrefix + this.utils.printDiffOrStringify(expected, receivedString, labelExpected, 'Received string', false) + callLogText(log);
}
};
return { return {
name: matcherName, name: matcherName,
@ -91,5 +96,10 @@ export async function toMatchText(
actual: received, actual: received,
log, log,
timeout: timedOut ? timeout : undefined, timeout: timedOut ? timeout : undefined,
locator: receiver.toString(),
header,
printedReceived,
printedExpected,
}; };
} }

View file

@ -38,9 +38,9 @@ type ErrorDetails = {
type TestResultErrorDetails = ErrorDetails & { type TestResultErrorDetails = ErrorDetails & {
shortMessage?: string; shortMessage?: string;
log?: string[]; log?: string[];
expected?: any; locator?: string;
actual?: any; expected?: string;
actual?: string;
snippet?: string; snippet?: string;
stack?: string; stack?: string;
}; };
@ -396,6 +396,7 @@ export function formatResultFailure(test: TestCase, result: TestResult, initialI
location: formattedError.location, location: formattedError.location,
shortMessage: error.shortMessage, shortMessage: error.shortMessage,
log: error.log, log: error.log,
locator: error.locator,
expected: error.expected, expected: error.expected,
actual: error.actual, actual: error.actual,
snippet: error.snippet, snippet: error.snippet,

View file

@ -25,36 +25,22 @@ import type { TestInfoError } from './../types/test';
import type { Location } from './../types/testReporter'; import type { Location } from './../types/testReporter';
import { calculateSha1, isRegExp, isString, sanitizeForFilePath, stringifyStackFrames } from 'playwright-core/lib/utils'; import { calculateSha1, isRegExp, isString, sanitizeForFilePath, stringifyStackFrames } from 'playwright-core/lib/utils';
import type { RawStack } from 'playwright-core/lib/utils'; import type { RawStack } from 'playwright-core/lib/utils';
import type { MatcherResult } from './matchers/matcherHint';
const PLAYWRIGHT_TEST_PATH = path.join(__dirname, '..'); const PLAYWRIGHT_TEST_PATH = path.join(__dirname, '..');
const PLAYWRIGHT_CORE_PATH = path.dirname(require.resolve('playwright-core/package.json')); const PLAYWRIGHT_CORE_PATH = path.dirname(require.resolve('playwright-core/package.json'));
export function filterStackTrace(e: Error): Omit<TestInfoError, 'value'> { export function filterStackTrace(e: Error): { message: string, stack: string } {
const name = e.name ? e.name + ': ' : ''; const name = e.name ? e.name + ': ' : '';
if (process.env.PWDEBUGIMPL) if (process.env.PWDEBUGIMPL)
return { message: name + e.message, stack: e.stack || '', ...filterExpectDetails(e) }; return { message: name + e.message, stack: e.stack || '' };
const stackLines = stringifyStackFrames(filteredStackTrace(e.stack?.split('\n') || [])); const stackLines = stringifyStackFrames(filteredStackTrace(e.stack?.split('\n') || []));
return { return {
...filterExpectDetails(e),
message: name + e.message, message: name + e.message,
stack: `${name}${e.message}${stackLines.map(line => '\n' + line).join('')}` stack: `${name}${e.message}${stackLines.map(line => '\n' + line).join('')}`
}; };
} }
function filterExpectDetails(e: Error): Pick<TestInfoError, 'shortMessage'|'log'|'expected'|'actual'> {
const matcherResult = (e as any).matcherResult as MatcherResult<unknown, unknown>;
if (!matcherResult)
return {};
return {
shortMessage: matcherResult.shortMessage,
log: matcherResult.log,
expected: matcherResult.expected,
actual: matcherResult.actual,
};
}
export function filterStackFile(file: string) { export function filterStackFile(file: string) {
if (!process.env.PWDEBUGIMPL && file.startsWith(PLAYWRIGHT_TEST_PATH)) if (!process.env.PWDEBUGIMPL && file.startsWith(PLAYWRIGHT_TEST_PATH))
return false; return false;

View file

@ -25,15 +25,16 @@ export function serializeWorkerError(error: Error | any): TestInfoError {
}; };
} }
function serializeExpectDetails(e: Error): Pick<TestInfoError, 'shortMessage'|'log'|'expected'|'actual'> { function serializeExpectDetails(e: Error): Pick<TestInfoError, 'shortMessage'|'log'|'expected'|'actual'|'locator'> {
const matcherResult = (e as any).matcherResult as MatcherResult<unknown, unknown>; const matcherResult = (e as any).matcherResult as MatcherResult<unknown, unknown>;
if (!matcherResult) if (!matcherResult)
return {}; return {};
return { return {
shortMessage: matcherResult.shortMessage, shortMessage: matcherResult.header,
log: matcherResult.log, log: matcherResult.log,
expected: matcherResult.expected, expected: matcherResult.printedExpected,
actual: matcherResult.actual, actual: matcherResult.printedReceived,
locator: matcherResult.locator,
}; };
} }

View file

@ -9152,12 +9152,17 @@ export interface TestInfoError {
/** /**
* Actual value. * Actual value.
*/ */
actual?: any; actual?: string;
/** /**
* Expected value. * Expected value.
*/ */
expected?: any; expected?: string;
/**
* Receiver's locator.
*/
locator?: string;
/** /**
* Call log. * Call log.

View file

@ -557,18 +557,23 @@ export interface TestError {
/** /**
* Actual value. * Actual value.
*/ */
actual?: any; actual?: string;
/** /**
* Expected value. * Expected value.
*/ */
expected?: any; expected?: string;
/** /**
* Error location in the source code. * Error location in the source code.
*/ */
location?: Location; location?: Location;
/**
* Receiver's locator.
*/
locator?: string;
/** /**
* Call log. * Call log.
*/ */

View file

@ -18,7 +18,10 @@
import { test as it, expect } from './pageTest'; import { test as it, expect } from './pageTest';
it('should check the box @smoke', async ({ page }) => { it('should check the box @smoke', async ({ page }) => {
await page.setContent(`<input id='checkbox' type='checkbox'></input>`); await page.setContent(`<div>foo</div>`);
// expect(['fobao', 'bar']).toBe(expect.arrayContaining([expect.stringContaining('oba'), expect.stringContaining('bar')]));
expect('fobaro').toBe('bar');
await expect(page.locator('div')).toHaveText('fobaro', { timeout: 500 });
await page.check('input'); await page.check('input');
expect(await page.evaluate(() => window['checkbox'].checked)).toBe(true); expect(await page.evaluate(() => window['checkbox'].checked)).toBe(true);
}); });